diff --git a/lib/db/hive/db.dart b/lib/db/hive/db.dart index 3eac4b805..03d0fd303 100644 --- a/lib/db/hive/db.dart +++ b/lib/db/hive/db.dart @@ -11,11 +11,12 @@ import 'dart:isolate'; import 'package:compat/compat.dart' as lib_monero_compat; -import 'package:hive_ce/src/hive_impl.dart'; import 'package:hive_ce/hive.dart' show Box; +import 'package:hive_ce/src/hive_impl.dart'; import 'package:mutex/mutex.dart'; import '../../app_config.dart'; +import '../../models/epicbox_server_model.dart'; import '../../models/exchange/response_objects/trade.dart'; import '../../models/node_model.dart'; import '../../models/notification_model.dart'; @@ -52,6 +53,8 @@ class DB { static const String boxNameDBInfo = "dbInfo"; static const String boxNamePrefs = "prefs"; static const String boxNameOneTimeDialogsShown = "oneTimeDialogsShown"; + static const String boxNameEpicBoxModels = "epicBoxModels"; + static const String boxNamePrimaryEpicBox = "primaryEpicBox"; String _boxNameTxCache({required CryptoCurrency currency}) => "${currency.identifier}_txCache"; @@ -75,6 +78,8 @@ class DB { Box? _boxPrefs; Box? _boxTradeLookup; Box? _boxDBInfo; + late final Box _boxEpicBoxModels; + late final Box _boxPrimaryEpicBoxes; // Box? _boxDesktopData; final Map> _walletBoxes = {}; @@ -115,6 +120,24 @@ class DB { } await hive.openBox(boxNameWalletsToDeleteOnStart); + if (hive.isBoxOpen(boxNameEpicBoxModels)) { + _boxEpicBoxModels = hive.box(boxNameEpicBoxModels); + } else { + _boxEpicBoxModels = await hive.openBox( + boxNameEpicBoxModels, + ); + } + + if (hive.isBoxOpen(boxNamePrimaryEpicBox)) { + _boxPrimaryEpicBoxes = hive.box( + boxNamePrimaryEpicBox, + ); + } else { + _boxPrimaryEpicBoxes = await hive.openBox( + boxNamePrimaryEpicBox, + ); + } + if (hive.isBoxOpen(boxNamePrefs)) { _boxPrefs = hive.box(boxNamePrefs); } else { diff --git a/lib/main.dart b/lib/main.dart index dd35ae7b5..ea6880af6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,6 +33,7 @@ import 'db/hive/db.dart'; import 'db/isar/main_db.dart'; import 'db/special_migrations.dart'; import 'db/sqlite/firo_cache.dart'; +import 'models/epicbox_server_model.dart'; import 'models/exchange/change_now/exchange_transaction.dart'; import 'models/exchange/change_now/exchange_transaction_status.dart'; import 'models/exchange/response_objects/trade.dart'; @@ -155,6 +156,9 @@ void main(List args) async { // node model adapter DB.instance.hive.registerAdapter(NodeModelAdapter()); + // epicbox server model adapter + DB.instance.hive.registerAdapter(EpicBoxServerModelAdapter()); + if (!DB.instance.hive.isAdapterRegistered( lib_monero_compat.WalletInfoAdapter().typeId, )) { @@ -390,6 +394,7 @@ class _MaterialAppWithThemeState extends ConsumerState unawaited(ref.read(baseCurrenciesProvider).update()); await _nodeService.updateDefaults(); + await _nodeService.updateDefaultEpicBoxes(); await _notificationsService.init( nodeService: _nodeService, tradesService: _tradesService, diff --git a/lib/models/isar/models/blockchain_data/v2/transaction_v2.dart b/lib/models/isar/models/blockchain_data/v2/transaction_v2.dart index 4bb9e1fa8..721d9a11c 100644 --- a/lib/models/isar/models/blockchain_data/v2/transaction_v2.dart +++ b/lib/models/isar/models/blockchain_data/v2/transaction_v2.dart @@ -277,7 +277,8 @@ class TransactionV2 { if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { return "Received"; } else { - if (numberOfMessages == 1) { + if ((onChainNote == null && numberOfMessages == 1) | + (onChainNote != null && numberOfMessages == 2)) { return "Receiving (waiting for sender)"; } else if ((numberOfMessages ?? 0) > 1) { return "Receiving (waiting for confirmations)"; // TODO test if the sender still has to open again after the receiver has 2 messages present, ie. sender->receiver->sender->node (yes) vs. sender->receiver->node (no) @@ -289,7 +290,8 @@ class TransactionV2 { if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { return "Sent (confirmed)"; } else { - if (numberOfMessages == 1) { + if ((onChainNote == null && numberOfMessages == 1) | + (onChainNote != null && numberOfMessages == 2)) { return "Sending (waiting for receiver)"; } else if ((numberOfMessages ?? 0) > 1) { return "Sending (waiting for confirmations)"; @@ -311,7 +313,8 @@ class TransactionV2 { if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { return "Received"; } else { - if (numberOfMessages == 1) { + if ((onChainNote == null && numberOfMessages == 1) | + (onChainNote != null && numberOfMessages == 2)) { return "Receiving (waiting for sender)"; } else if ((numberOfMessages ?? 0) > 1) { return "Receiving (waiting for confirmations)"; // TODO test if the sender still has to open again after the receiver has 2 messages present, ie. sender->receiver->sender->node (yes) vs. sender->receiver->node (no) @@ -323,41 +326,8 @@ class TransactionV2 { if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { return "Sent (confirmed)"; } else { - if (numberOfMessages == 1) { - return "Sending (waiting for receiver)"; - } else if ((numberOfMessages ?? 0) > 1) { - return "Sending (waiting for confirmations)"; - } else { - return "Sending ${prettyConfirms()}"; - } - } - } - } - - if (isMimblewimblecoinTransaction) { - if (slateId == null) { - return "Restored Funds"; - } - - if (isCancelled) { - return "Cancelled"; - } else if (type == TransactionType.incoming) { - if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { - return "Received"; - } else { - if (numberOfMessages == 1) { - return "Receiving (waiting for sender)"; - } else if ((numberOfMessages ?? 0) > 1) { - return "Receiving (waiting for confirmations)"; // TODO test if the sender still has to open again after the receiver has 2 messages present, ie. sender->receiver->sender->node (yes) vs. sender->receiver->node (no) - } else { - return "Receiving ${prettyConfirms()}"; - } - } - } else if (type == TransactionType.outgoing) { - if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { - return "Sent (confirmed)"; - } else { - if (numberOfMessages == 1) { + if ((onChainNote == null && numberOfMessages == 1) | + (onChainNote != null && numberOfMessages == 2)) { return "Sending (waiting for receiver)"; } else if ((numberOfMessages ?? 0) > 1) { return "Sending (waiting for confirmations)"; diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 48b5f1c66..4771a10ac 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -26,8 +26,12 @@ class HTTP { required Uri url, Map? headers, required ({InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, }) async { final httpClient = HttpClient(); + if (connectionTimeout != null) { + httpClient.connectionTimeout = connectionTimeout; + } try { if (proxyInfo != null) { SocksTCPClient.assignToHttpClient(httpClient, [ diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 9a7640a86..dfd6c98bd 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -1181,7 +1181,7 @@ class _ConfirmTransactionViewState children: [ if (coin is Epiccash || coin is Mimblewimblecoin) Text( - "On chain Note (optional)", + "On chain Note", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 8761a7234..94b5663c8 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -2353,7 +2353,7 @@ class _SendViewState extends ConsumerState { const SizedBox(height: 12), if (coin is Epiccash) Text( - "On chain Note (optional)", + "On chain Note", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), diff --git a/lib/pages/settings_views/wallet_settings_view/epicbox_settings/add_edit_epicbox_mobile_view.dart b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/add_edit_epicbox_mobile_view.dart new file mode 100644 index 000000000..907aaafde --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/add_edit_epicbox_mobile_view.dart @@ -0,0 +1,463 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../models/epicbox_server_model.dart'; +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/global/node_service_provider.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/test_epicbox_server_connection.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../widgets/background.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/textfield_icon_button.dart'; + +enum AddEditEpicboxMobileViewType { add, edit } + +class AddEditEpicboxMobileView extends ConsumerStatefulWidget { + const AddEditEpicboxMobileView({ + super.key, + required this.viewType, + this.epicBoxId, + required this.routeOnSuccessOrDelete, + }) : assert( + (viewType == .edit && epicBoxId != null) || + viewType == .add && epicBoxId == null, + ); + + static const routeName = "/addEditEpicboxMobile"; + + final AddEditEpicboxMobileViewType viewType; + final String? epicBoxId; + final String routeOnSuccessOrDelete; + + @override + ConsumerState createState() => + _AddEditEpicboxMobileViewState(); +} + +class _AddEditEpicboxMobileViewState + extends ConsumerState { + late final TextEditingController _nameController; + late final TextEditingController _hostController; + late final TextEditingController _portController; + + final _nameFocusNode = FocusNode(); + final _hostFocusNode = FocusNode(); + final _portFocusNode = FocusNode(); + + bool _useSSL = true; + int? port; + + bool get canSave { + return _nameController.text.isNotEmpty && canTestConnection; + } + + bool get canTestConnection { + return _hostController.text.isNotEmpty && + port != null && + port! >= 0 && + port! <= 65535; + } + + Future _testConnection() async { + final data = EpicBoxFormData() + ..name = _nameController.text + ..host = _hostController.text + ..port = port ?? 443 + ..useSSL = _useSSL; + + final result = await testEpicBoxServerConnection(data); + if (!mounted) return; + + if (result != null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Connection successful", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not connect to server", + context: context, + ), + ); + } + } + + Future _attemptSave() async { + final data = EpicBoxFormData() + ..name = _nameController.text + ..host = _hostController.text + ..port = port ?? 443 + ..useSSL = _useSSL; + + final canConnect = await testEpicBoxServerConnection(data) != null; + + bool shouldSave = canConnect; + + if (!canConnect && mounted) { + await showDialog( + context: context, + useSafeArea: true, + barrierDismissible: true, + builder: (context) => AlertDialog( + title: const Text("Server currently unreachable"), + content: const Text("Would you like to save this server anyways?"), + actions: [ + // todo both pop until routeOnSuccessOrDelete ? + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + "Save", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ], + ), + ).then((value) { + if (value == true) { + shouldSave = true; + } + }); + } + + if (!shouldSave) return; + + final epicBox = EpicBoxServerModel( + id: widget.epicBoxId ?? const Uuid().v1(), + host: _hostController.text, + port: port ?? 443, + name: _nameController.text, + useSSL: _useSSL, + enabled: true, + isFailover: true, + isDown: false, + ); + + await ref.read(nodeServiceChangeNotifierProvider).addEpicBox(epicBox, true); + + if (mounted) { + Navigator.of(context).pop(); + } + } + + late final bool canDelete; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _hostController = TextEditingController(); + _portController = TextEditingController(); + + switch (widget.viewType) { + case .add: + _portController.text = "443"; + port = 443; + canDelete = false; + break; + + case .edit: + final epicBox = ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: widget.epicBoxId!)!; + + _nameController.text = epicBox.name; + _hostController.text = epicBox.host; + _portController.text = (epicBox.port ?? 443).toString(); + _useSSL = epicBox.useSSL ?? true; + port = epicBox.port ?? 443; + canDelete = !epicBox.isDefault; + break; + } + } + + @override + void dispose() { + _nameController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _nameFocusNode.dispose(); + _hostFocusNode.dispose(); + _portFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + widget.viewType == AddEditEpicboxMobileViewType.add + ? "Add Epicbox Server" + : "Edit Epicbox Server", + style: STextStyles.navBarTitle(context), + ), + actions: [ + if (canDelete) + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 10), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("deleteNodeAppBarButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.trash, + color: Theme.of( + context, + ).extension()!.accentColorDark, + width: 20, + height: 20, + ), + onPressed: () async { + Navigator.popUntil( + context, + ModalRoute.withName(widget.routeOnSuccessOrDelete), + ); + await ref + .read(nodeServiceChangeNotifierProvider) + .deleteEpicBox(widget.epicBoxId!, true); + }, + ), + ), + ), + ], + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _nameController, + focusNode: _nameFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Server name", + _nameFocusNode, + context, + ).copyWith( + suffixIcon: _nameController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _nameController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _hostController, + focusNode: _hostFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Host", + _hostFocusNode, + context, + ).copyWith( + suffixIcon: _hostController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _hostController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _portController, + focusNode: _portFocusNode, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + keyboardType: TextInputType.number, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Port", + _portFocusNode, + context, + ).copyWith( + suffixIcon: _portController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _portController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (value) { + port = int.tryParse(value); + setState(() {}); + }, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + GestureDetector( + onTap: () { + setState(() { + _useSSL = !_useSSL; + }); + }, + child: Container( + color: Colors.transparent, + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: Checkbox( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + value: _useSSL, + onChanged: (newValue) { + setState(() { + _useSSL = newValue!; + }); + }, + ), + ), + const SizedBox(width: 12), + Text( + "Use SSL", + style: STextStyles.itemSubtitle12( + context, + ), + ), + ], + ), + ), + ), + ], + ), + + const Spacer(), + const SizedBox(height: 16), + SecondaryButton( + label: "Test connection", + enabled: canTestConnection, + onPressed: canTestConnection + ? _testConnection + : null, + ), + const SizedBox(height: 16), + PrimaryButton( + label: "Save", + onPressed: canSave ? _attemptSave : null, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart new file mode 100644 index 000000000..797ce8e01 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart @@ -0,0 +1,217 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/providers.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/default_epicboxes.dart'; +import '../../../../utilities/test_epicbox_server_connection.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../widgets/background.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/epicbox_card.dart'; +import 'add_edit_epicbox_mobile_view.dart'; + +class ManageEpicboxView extends ConsumerStatefulWidget { + const ManageEpicboxView({super.key, required this.walletId}); + + static const routeName = "/manageEpicbox"; + + final String walletId; + + @override + ConsumerState createState() => _ManageEpicboxViewState(); +} + +class _ManageEpicboxViewState extends ConsumerState { + Future _onConnect(String epicBoxId) async { + final epicBox = + ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: epicBoxId) ?? + DefaultEpicBoxes.all.firstWhere((e) => e.id == epicBoxId); + + final data = EpicBoxFormData() + ..host = epicBox.host + ..port = epicBox.port ?? 443 + ..useSSL = epicBox.useSSL; + + final canConnect = await testEpicBoxServerConnection(data) != null; + + if (!canConnect && mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + iconAsset: Assets.svg.circleAlert, + message: "Could not connect to server", + context: context, + ), + ); + return; + } + + await ref + .read(nodeServiceChangeNotifierProvider) + .setPrimaryEpicBox(epicBox: epicBox, shouldNotifyListeners: true); + + // update wallet's epicbox config + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; + await wallet.updateEpicboxConfig(epicBox.host, epicBox.port ?? 443); + + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Connected to ${epicBox.name}", + context: context, + ), + ); + } + } + + void _onEdit(String epicBoxId) { + Navigator.of(context).pushNamed( + AddEditEpicboxMobileView.routeName, + arguments: ( + viewType: AddEditEpicboxMobileViewType.edit, + epicBoxId: epicBoxId, + routeOnSuccessOrDelete: ManageEpicboxView.routeName, + ), + ); + } + + void _onAdd() { + Navigator.of(context).pushNamed( + AddEditEpicboxMobileView.routeName, + arguments: ( + viewType: AddEditEpicboxMobileViewType.add, + epicBoxId: null, + routeOnSuccessOrDelete: ManageEpicboxView.routeName, + ), + ); + } + + @override + Widget build(BuildContext context) { + final epicBoxes = ref.watch( + nodeServiceChangeNotifierProvider.select((value) => value.getEpicBoxes()), + ); + final primaryEpicBox = ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getPrimaryEpicBox(), + ), + ); + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Epicbox Servers", + style: STextStyles.navBarTitle(context), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + icon: SizedBox( + width: 20, + height: 20, + child: Center( + child: Icon( + Icons.add, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + size: 20, + ), + ), + ), + onPressed: _onAdd, + ), + ), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + "Default servers", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ), + ...DefaultEpicBoxes.all.map( + (epicBox) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: EpicBoxCard( + key: Key("${epicBox.id}_card_key"), + epicBoxId: epicBox.id, + onConnect: () => _onConnect(epicBox.id), + onEdit: () {}, + testOnInit: primaryEpicBox?.id == epicBox.id, + ), + ), + ), + ], + if (epicBoxes.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + "Custom servers", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ), + ...epicBoxes.map( + (epicBox) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: EpicBoxCard( + key: Key("${epicBox.id}_card_key"), + epicBoxId: epicBox.id, + onConnect: () => _onConnect(epicBox.id), + onEdit: () => _onEdit(epicBox.id), + testOnInit: primaryEpicBox?.id == epicBox.id, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index 58ffaad71..4fd2e8f95 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart @@ -55,6 +55,7 @@ import '../../home_view/home_view.dart'; import '../../pinpad_views/lock_screen_view.dart'; import '../global_settings_view/syncing_preferences_views/syncing_preferences_view.dart'; import '../sub_widgets/settings_list_button.dart'; +import 'epicbox_settings/manage_epicbox_view.dart'; import 'frost_ms/frost_ms_options_view.dart'; import 'wallet_backup_views/wallet_backup_view.dart'; import 'wallet_network_settings_view/wallet_network_settings_view.dart'; @@ -409,6 +410,19 @@ class _WalletSettingsViewState extends ConsumerState { ); }, ), + if (wallet is EpiccashWallet) const SizedBox(height: 8), + if (wallet is EpiccashWallet) + SettingsListButton( + iconAssetName: Assets.svg.node, + iconSize: 16, + title: "Epicbox Servers", + onPressed: () { + Navigator.of(context).pushNamed( + ManageEpicboxView.routeName, + arguments: walletId, + ); + }, + ), if (canBackup) const SizedBox(height: 8), if (canBackup) Consumer( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart index 4beb82514..dc7fc8b3a 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart @@ -38,6 +38,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart' import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../addresses/desktop_wallet_addresses_view.dart'; import '../../../password/request_desktop_auth_dialog.dart'; +import '../../../settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart'; import 'desktop_delete_wallet_dialog.dart'; enum _WalletOptions { @@ -47,7 +48,8 @@ enum _WalletOptions { showXpub, frostOptions, refreshFromHeight, - showSparkKey; + showSparkKey, + epicBoxSettings; String get prettyName { switch (this) { @@ -65,6 +67,8 @@ enum _WalletOptions { return "Refresh height"; case _WalletOptions.showSparkKey: return "Show Spark View Key"; + case _WalletOptions.epicBoxSettings: + return "Epic Box settings"; } } } @@ -125,6 +129,9 @@ class WalletOptionsButton extends ConsumerWidget { onRefreshHeightPressed: () async { Navigator.of(context).pop(_WalletOptions.refreshFromHeight); }, + onEpicBoxSettingsPressed: () async { + Navigator.of(context).pop(_WalletOptions.epicBoxSettings); + }, walletId: walletId, ); }, @@ -296,6 +303,16 @@ class WalletOptionsButton extends ConsumerWidget { ); } break; + + case _WalletOptions.epicBoxSettings: + unawaited( + showDialog( + context: context, + builder: (context) => + DesktopManageEpicBoxDialog(walletId: walletId), + ), + ); + break; } } }, @@ -327,6 +344,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { required this.onChangeRepPressed, required this.onFrostMSWalletOptionsPressed, required this.onRefreshHeightPressed, + required this.onEpicBoxSettingsPressed, required this.walletId, }); @@ -336,6 +354,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final VoidCallback onChangeRepPressed; final VoidCallback onFrostMSWalletOptionsPressed; final VoidCallback onRefreshHeightPressed; + final VoidCallback onEpicBoxSettingsPressed; final String walletId; @override @@ -515,6 +534,41 @@ class WalletOptionsPopupMenu extends ConsumerWidget { ), ), ), + if (wallet is EpiccashWallet) const SizedBox(height: 8), + if (wallet is EpiccashWallet) + TransparentButton( + onPressed: onEpicBoxSettingsPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SvgPicture.asset( + Assets.svg.node, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + _WalletOptions.epicBoxSettings.prettyName, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ), + ], + ), + ), + ), if (xpubEnabled) const SizedBox(height: 8), if (xpubEnabled) TransparentButton( diff --git a/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/add_edit_epicbox_view.dart b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/add_edit_epicbox_view.dart new file mode 100644 index 000000000..07f504621 --- /dev/null +++ b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/add_edit_epicbox_view.dart @@ -0,0 +1,482 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../models/epicbox_server_model.dart'; +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/global/node_service_provider.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/test_epicbox_server_connection.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/desktop/delete_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/textfield_icon_button.dart'; + +enum AddEditEpicBoxViewType { add, edit } + +class AddEditEpicBoxView extends ConsumerStatefulWidget { + const AddEditEpicBoxView({ + super.key, + required this.viewType, + this.epicBoxId, + required this.onSave, + }) : assert( + (viewType == .edit && epicBoxId != null) || + viewType == .add && epicBoxId == null, + ); + + final AddEditEpicBoxViewType viewType; + final String? epicBoxId; + final VoidCallback onSave; + + @override + ConsumerState createState() => _AddEditEpicBoxViewState(); +} + +class _AddEditEpicBoxViewState extends ConsumerState { + late final TextEditingController _nameController; + late final TextEditingController _hostController; + late final TextEditingController _portController; + + final _nameFocusNode = FocusNode(); + final _hostFocusNode = FocusNode(); + final _portFocusNode = FocusNode(); + + bool _useSSL = true; + int? port; + + bool get canSave { + return _nameController.text.isNotEmpty && canTestConnection; + } + + bool get canTestConnection { + return _hostController.text.isNotEmpty && + port != null && + port! >= 0 && + port! <= 65535; + } + + Future _testConnection() async { + final data = EpicBoxFormData() + ..name = _nameController.text + ..host = _hostController.text + ..port = port ?? 443 + ..useSSL = _useSSL; + + final result = await testEpicBoxServerConnection(data); + if (!mounted) return; + + if (result != null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Connection successful", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not connect to server", + context: context, + ), + ); + } + } + + Future _attemptSave() async { + final data = EpicBoxFormData() + ..name = _nameController.text + ..host = _hostController.text + ..port = port ?? 443 + ..useSSL = _useSSL; + + final canConnect = await testEpicBoxServerConnection(data) != null; + + bool shouldSave = canConnect; + + if (!canConnect && mounted) { + await showDialog( + context: context, + useSafeArea: true, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxWidth: 440, + maxHeight: 300, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 32), + child: Row( + children: [ + const SizedBox(width: 32), + Text( + "Server currently unreachable", + style: STextStyles.desktopH3(context), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + top: 16, + bottom: 32, + ), + child: Column( + children: [ + const Spacer(), + Text( + "Would you like to save this server anyways?", + style: STextStyles.desktopTextMedium(context), + ), + const Spacer(flex: 2), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(false), + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Save", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(true), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ).then((value) { + if (value is bool && value) { + shouldSave = true; + } + }); + } + + if (!shouldSave) return; + + final epicBox = EpicBoxServerModel( + id: widget.epicBoxId ?? const Uuid().v1(), + host: _hostController.text, + port: port ?? 443, + name: _nameController.text, + useSSL: _useSSL, + enabled: true, + isFailover: true, + isDown: false, + ); + + await ref.read(nodeServiceChangeNotifierProvider).addEpicBox(epicBox, true); + widget.onSave(); + + if (mounted) { + Navigator.of(context).pop(); + } + } + + late final bool canDelete; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _hostController = TextEditingController(); + _portController = TextEditingController(); + + switch (widget.viewType) { + case .add: + _portController.text = "443"; + port = 443; + canDelete = false; + break; + + case .edit: + final epicBox = ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: widget.epicBoxId!)!; + + _nameController.text = epicBox.name; + _hostController.text = epicBox.host; + _portController.text = (epicBox.port ?? 443).toString(); + _useSSL = epicBox.useSSL ?? true; + port = epicBox.port ?? 443; + canDelete = !epicBox.isDefault; + break; + } + } + + @override + void dispose() { + _nameController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _nameFocusNode.dispose(); + _hostFocusNode.dispose(); + _portFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const SizedBox(width: 8), + const AppBarBackButton(iconSize: 24, size: 40), + Text( + widget.viewType == AddEditEpicBoxViewType.add + ? "Add Epic Box server" + : "Edit Epic Box server", + style: STextStyles.desktopH3(context), + ), + ], + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + top: 16, + bottom: 32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _nameController, + focusNode: _nameFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Server name", + _nameFocusNode, + context, + ).copyWith( + suffixIcon: _nameController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _nameController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _hostController, + focusNode: _hostFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Host", + _hostFocusNode, + context, + ).copyWith( + suffixIcon: _hostController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _hostController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _portController, + focusNode: _portFocusNode, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + keyboardType: TextInputType.number, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Port", + _portFocusNode, + context, + ).copyWith( + suffixIcon: _portController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _portController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (value) { + port = int.tryParse(value); + setState(() {}); + }, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + GestureDetector( + onTap: () { + setState(() { + _useSSL = !_useSSL; + }); + }, + child: Container( + color: Colors.transparent, + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: Checkbox( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + value: _useSSL, + onChanged: (newValue) { + setState(() { + _useSSL = newValue!; + }); + }, + ), + ), + const SizedBox(width: 12), + Text( + "Use SSL", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 22), + if (canDelete) + SizedBox( + height: 56, + child: Row( + children: [ + Expanded( + child: DeleteButton( + label: "Delete node", + desktopMed: true, + onPressed: () { + Navigator.of(context).pop(); + ref + .read(nodeServiceChangeNotifierProvider) + .deleteEpicBox(widget.epicBoxId!, true); + }, + ), + ), + const SizedBox(width: 16), + const Spacer(), + ], + ), + ), + if (canDelete) const SizedBox(height: 45), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Test connection", + enabled: canTestConnection, + buttonHeight: ButtonHeight.l, + onPressed: canTestConnection ? _testConnection : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Save", + enabled: canSave, + buttonHeight: ButtonHeight.l, + onPressed: canSave ? _attemptSave : null, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart new file mode 100644 index 000000000..5dab7e2d5 --- /dev/null +++ b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart @@ -0,0 +1,210 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/providers.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/default_epicboxes.dart'; +import '../../../../utilities/test_epicbox_server_connection.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../../widgets/epicbox_card.dart'; +import 'add_edit_epicbox_view.dart'; + +class DesktopManageEpicBoxDialog extends ConsumerStatefulWidget { + const DesktopManageEpicBoxDialog({super.key, required this.walletId}); + + final String walletId; + + @override + ConsumerState createState() => + _DesktopManageEpicBoxDialogState(); +} + +class _DesktopManageEpicBoxDialogState + extends ConsumerState { + Future _onConnect(String epicBoxId) async { + final epicBox = + ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: epicBoxId) ?? + DefaultEpicBoxes.all.firstWhere((e) => e.id == epicBoxId); + + final data = EpicBoxFormData() + ..host = epicBox.host + ..port = epicBox.port ?? 443 + ..useSSL = epicBox.useSSL; + + final canConnect = await testEpicBoxServerConnection(data) != null; + + if (!canConnect && mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + iconAsset: Assets.svg.circleAlert, + message: "Could not connect to server", + context: context, + ), + ); + return; + } + + await ref + .read(nodeServiceChangeNotifierProvider) + .setPrimaryEpicBox(epicBox: epicBox, shouldNotifyListeners: true); + + // update wallet's epicbox config + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; + await wallet.updateEpicboxConfig(epicBox.host, epicBox.port ?? 443); + + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Connected to ${epicBox.name}", + context: context, + ), + ); + } + } + + void _onEdit(String epicBoxId) { + showDialog( + context: context, + builder: (_) => AddEditEpicBoxView( + viewType: AddEditEpicBoxViewType.edit, + epicBoxId: epicBoxId, + onSave: () {}, + ), + ); + } + + void _onAdd() { + showDialog( + context: context, + builder: (_) => AddEditEpicBoxView( + viewType: AddEditEpicBoxViewType.add, + onSave: () {}, + ), + ); + } + + @override + Widget build(BuildContext context) { + final epicBoxes = ref.watch( + nodeServiceChangeNotifierProvider.select((value) => value.getEpicBoxes()), + ); + final primaryEpicBox = ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getPrimaryEpicBox(), + ), + ); + + return DesktopDialog( + maxHeight: double.infinity, + maxWidth: 580, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Epic Box", style: STextStyles.desktopH3(context)), + const DesktopDialogCloseButton(), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Servers", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + CustomTextButton(text: "Add new", onTap: _onAdd), + ], + ), + ), + const SizedBox(height: 12), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + "Default servers", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ), + ...DefaultEpicBoxes.all.map( + (epicBox) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: EpicBoxCard( + key: Key("${epicBox.id}_card_key"), + epicBoxId: epicBox.id, + onConnect: () => _onConnect(epicBox.id), + onEdit: () {}, // do nothing for defaults + testOnInit: primaryEpicBox?.id == epicBox.id, + ), + ), + ), + if (epicBoxes.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + "Custom servers", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ), + ...epicBoxes.map( + (epicBox) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: EpicBoxCard( + key: Key("${epicBox.id}_card_key"), + epicBoxId: epicBox.id, + onConnect: () => _onConnect(epicBox.id), + onEdit: () => _onEdit(epicBox.id), + testOnInit: primaryEpicBox?.id == epicBox.id, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 21c20a074..cad05cbcd 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -144,6 +144,8 @@ import 'pages/settings_views/global_settings_view/syncing_preferences_views/sync import 'pages/settings_views/global_settings_view/syncing_preferences_views/syncing_preferences_view.dart'; import 'pages/settings_views/global_settings_view/syncing_preferences_views/wallet_syncing_options_view.dart'; import 'pages/settings_views/global_settings_view/tor_settings/tor_settings_view.dart'; +import 'pages/settings_views/wallet_settings_view/epicbox_settings/add_edit_epicbox_mobile_view.dart'; +import 'pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/initiate_resharing/complete_reshare_config_view.dart'; @@ -1438,6 +1440,35 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case ManageEpicboxView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ManageEpicboxView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case AddEditEpicboxMobileView.routeName: + if (args + is ({ + AddEditEpicboxMobileViewType viewType, + String? epicBoxId, + String routeOnSuccessOrDelete, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => AddEditEpicboxMobileView( + viewType: args.viewType, + epicBoxId: args.epicBoxId, + routeOnSuccessOrDelete: args.routeOnSuccessOrDelete, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case WalletBackupView.routeName: if (args is ({String walletId, List mnemonic})) { return getRoute( diff --git a/lib/services/node_service.dart b/lib/services/node_service.dart index c1bc338b3..6a8329911 100644 --- a/lib/services/node_service.dart +++ b/lib/services/node_service.dart @@ -15,7 +15,9 @@ import 'package:http/http.dart'; import '../app_config.dart'; import '../db/hive/db.dart'; +import '../models/epicbox_server_model.dart'; import '../models/node_model.dart'; +import '../utilities/default_epicboxes.dart'; import '../utilities/default_nodes.dart'; import '../utilities/flutter_secure_storage_interface.dart'; import '../utilities/logger.dart'; @@ -166,15 +168,14 @@ class NodeService extends ChangeNotifier { } List getNodesFor(CryptoCurrency coin) { - final list = - DB.instance - .values(boxName: DB.boxNameNodeModels) - .where( - (e) => - e.coinName == coin.identifier && - !e.id.startsWith(DefaultNodes.defaultNodeIdPrefix), - ) - .toList(); + final list = DB.instance + .values(boxName: DB.boxNameNodeModels) + .where( + (e) => + e.coinName == coin.identifier && + !e.id.startsWith(DefaultNodes.defaultNodeIdPrefix), + ) + .toList(); // add default to end of list list.addAll( @@ -270,8 +271,10 @@ class NodeService extends ChangeNotifier { bool enabled, bool shouldNotifyListeners, ) async { - final model = - DB.instance.get(boxName: DB.boxNameNodeModels, key: id)!; + final model = DB.instance.get( + boxName: DB.boxNameNodeModels, + key: id, + )!; await DB.instance.put( boxName: DB.boxNameNodeModels, key: model.id, @@ -286,6 +289,103 @@ class NodeService extends ChangeNotifier { } } + //============================================================================ + // Epic Box server management + //============================================================================ + + Future updateDefaultEpicBoxes() async { + // final primaryEpicBox = getPrimaryEpicBox(); + // + // for (final defaultEpicBox in DefaultEpicBoxes.all) { + // final savedEpicBox = DB.instance.get( + // boxName: DB.boxNameEpicBoxModels, + // key: defaultEpicBox.id, + // ); + // if (savedEpicBox == null) { + // await DB.instance.put( + // boxName: DB.boxNameEpicBoxModels, + // key: defaultEpicBox.id, + // value: defaultEpicBox, + // ); + // } else { + // await DB.instance.put( + // boxName: DB.boxNameEpicBoxModels, + // key: savedEpicBox.id, + // value: defaultEpicBox.copyWith(enabled: savedEpicBox.enabled), + // ); + // } + // + // if (primaryEpicBox != null && primaryEpicBox.id == defaultEpicBox.id) { + // await setPrimaryEpicBox( + // epicBox: defaultEpicBox.copyWith(enabled: primaryEpicBox.enabled), + // ); + // } + // } + + // set default primary if none exists + if (getPrimaryEpicBox() == null) { + await setPrimaryEpicBox(epicBox: DefaultEpicBoxes.defaultEpicBoxServer); + } + } + + Future setPrimaryEpicBox({ + required EpicBoxServerModel epicBox, + bool shouldNotifyListeners = false, + }) async { + await DB.instance.put( + boxName: DB.boxNamePrimaryEpicBox, + key: 'primary', + value: epicBox, + ); + if (shouldNotifyListeners) { + notifyListeners(); + } + } + + EpicBoxServerModel? getPrimaryEpicBox() { + return DB.instance.get( + boxName: DB.boxNamePrimaryEpicBox, + key: 'primary', + ); + } + + List getEpicBoxes() { + return DB.instance + .values(boxName: DB.boxNameEpicBoxModels) + .toList(); + } + + EpicBoxServerModel? getEpicBoxById({required String id}) { + return DB.instance.get( + boxName: DB.boxNameEpicBoxModels, + key: id, + ); + } + + Future addEpicBox( + EpicBoxServerModel epicBox, + bool shouldNotifyListeners, + ) async { + await DB.instance.put( + boxName: DB.boxNameEpicBoxModels, + key: epicBox.id, + value: epicBox, + ); + if (shouldNotifyListeners) { + notifyListeners(); + } + } + + Future deleteEpicBox(String id, bool shouldNotifyListeners) async { + await DB.instance.delete( + boxName: DB.boxNameEpicBoxModels, + key: id, + ); + if (shouldNotifyListeners) { + notifyListeners(); + } + } + //============================================================================ Future updateCommunityNodes() async { diff --git a/lib/utilities/default_epicboxes.dart b/lib/utilities/default_epicboxes.dart index a2c9b01f0..3ab7f1732 100644 --- a/lib/utilities/default_epicboxes.dart +++ b/lib/utilities/default_epicboxes.dart @@ -13,41 +13,29 @@ import '../models/epicbox_server_model.dart'; abstract class DefaultEpicBoxes { static const String defaultName = "Default"; - static List get all => [americas, asia, europe]; - static List get defaultIds => ['americas', 'asia', 'europe']; + static List get all => [defaultEpicBoxServer, americas]; - static EpicBoxServerModel get americas => EpicBoxServerModel( - host: 'epicbox.stackwallet.com', - port: 443, - name: 'Americas', - id: 'americas', - useSSL: true, - enabled: true, - isFailover: true, - isDown: false, - ); - - static EpicBoxServerModel get asia => EpicBoxServerModel( - host: 'epicbox.hyperbig.com', - port: 443, - name: 'Asia', - id: 'asia', - useSSL: true, - enabled: true, - isFailover: true, - isDown: false, - ); + static EpicBoxServerModel get epiccashCom => EpicBoxServerModel( + host: 'epicbox.epiccash.com', + port: 443, + name: 'Official', + id: 'default_epiccashCom', + useSSL: true, + enabled: true, + isFailover: true, + isDown: false, + ); - static EpicBoxServerModel get europe => EpicBoxServerModel( - host: 'epicbox.fastepic.eu', - port: 443, - name: 'Europe', - id: 'europe', - useSSL: true, - enabled: true, - isFailover: true, - isDown: false, - ); + static EpicBoxServerModel get americas => EpicBoxServerModel( + host: 'epicbox.stackwallet.com', + port: 443, + name: 'Stack Wallet', + id: 'default_stack', + useSSL: true, + enabled: true, + isFailover: true, + isDown: false, + ); - static final defaultEpicBoxServer = americas; + static final defaultEpicBoxServer = epiccashCom; } diff --git a/lib/utilities/test_epicbox_server_connection.dart b/lib/utilities/test_epicbox_server_connection.dart new file mode 100644 index 000000000..0a2ef90ea --- /dev/null +++ b/lib/utilities/test_epicbox_server_connection.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'logger.dart'; + +Future _testEpicBoxConnection(String host, int port, bool useSSL) async { + final client = HttpClient(); + try { + final protocol = useSSL ? 'https' : 'http'; + + client.connectionTimeout = const Duration(seconds: 5); + + final request = await client.getUrl(Uri.parse('$protocol://$host:$port')); + final response = await request.close(); + final body = await response + .transform(const SystemEncoding().decoder) + .join(); + + client.close(); + + // epicbox servers return an HTML page containing "Epicbox" + return response.statusCode == 200 && body.contains('Epicbox'); + } catch (e, s) { + Logging.instance.e( + "_testEpicBoxConnection failed on \"$host:$port\"", + error: e, + stackTrace: s, + ); + return false; + } finally { + client.close(force: true); + } +} + +Future testEpicBoxServerConnection( + EpicBoxFormData data, +) async { + if (data.host == null || data.port == null) { + return null; + } + + try { + final useSSL = data.useSSL ?? true; + if (await _testEpicBoxConnection(data.host!, data.port!, useSSL)) { + return data; + } else { + return null; + } + } catch (e, s) { + Logging.instance.w("$e\n$s", error: e, stackTrace: s); + return null; + } +} + +class EpicBoxFormData { + String? name, host; + int? port; + bool? useSSL, isFailover; + + @override + String toString() { + return "{ name: $name, host: $host, port: $port, useSSL: $useSSL }"; + } +} diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index 66d929430..3746a4836 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -8,6 +8,7 @@ import 'package:mutex/mutex.dart'; import 'package:stack_wallet_backup/generate_password.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../../exceptions/main_db/main_db_exception.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; import '../../../models/balance.dart'; import '../../../models/epic_slatepack_models.dart'; @@ -120,10 +121,12 @@ class EpiccashWallet extends Bip39Wallet { "epicbox_address_index": 0, }); await secureStorageInterface.write( - key: '${walletId}_epicboxConfig', + key: '${walletId}_epicboxConfigNewNewNew', value: stringConfig, ); libEpic.updateEpicboxConfig(wallet: _wallet!, epicBoxConfig: stringConfig); + + await _generateAndStoreReceivingAddressForIndex(0); // TODO: refresh anything that needs to be refreshed/updated due to epicbox info changed } @@ -148,34 +151,27 @@ class EpiccashWallet extends Bip39Wallet { } Future getEpicBoxConfig() async { - final EpicBoxConfigModel _epicBoxConfig = EpicBoxConfigModel.fromServer( - DefaultEpicBoxes.defaultEpicBoxServer, + // check for user-configured epicbox first + final storedConfig = await secureStorageInterface.read( + key: '${walletId}_epicboxConfigNewNewNew', ); + if (storedConfig != null && storedConfig.isNotEmpty) { + try { + return EpicBoxConfigModel.fromString(storedConfig); + } catch (e, s) { + Logging.instance.e( + "Failed to parse stored epicbox config $storedConfig." + " Falling back to default.", + error: e, + stackTrace: s, + ); + } + } else { + Logging.instance.i("No stored epic box config. Falling back to default."); + } - //Get the default Epicbox server and check if it's conected - // bool isEpicboxConnected = await _testEpicboxServer( - // DefaultEpicBoxes.defaultEpicBoxServer.host, - // DefaultEpicBoxes.defaultEpicBoxServer.port ?? 443); - - // if (isEpicboxConnected) { - //Use default server for as Epicbox config - - // } - // else { - // //Use Europe config - // _epicBoxConfig = EpicBoxConfigModel.fromServer(DefaultEpicBoxes.europe); - // } - // // example of selecting another random server from the default list - // // alternative servers: copy list of all default EB servers but remove the default default - // // List alternativeServers = DefaultEpicBoxes.all; - // // alternativeServers.removeWhere((opt) => opt.name == DefaultEpicBoxes.defaultEpicBoxServer.name); - // // alternativeServers.shuffle(); // randomize which server is used - // // _epicBoxConfig = EpicBoxConfigModel.fromServer(alternativeServers.first); - // - // // TODO test this connection before returning it - // } - - return _epicBoxConfig; + // fall back to default + return EpicBoxConfigModel.fromServer(DefaultEpicBoxes.defaultEpicBoxServer); } Future updateRestoreHeight(int height) async { @@ -572,7 +568,7 @@ class EpiccashWallet extends Bip39Wallet { return response is String && response.contains("Challenge"); } catch (e, s) { Logging.instance.w( - "_testEpicBoxConnection failed on \"$host:$port\"", + "_testEpicboxServer failed on \"$host:$port\"", error: e, stackTrace: s, ); @@ -619,8 +615,33 @@ class EpiccashWallet extends Bip39Wallet { } } + Future _updateAddressInDB(Address address) async { + try { + final storedAddress = await getCurrentReceivingAddress(); + await mainDB.isar.writeTxn(() async { + if (storedAddress == null) { + await mainDB.isar.addresses.put(address); + } else { + address.id = storedAddress.id; + await storedAddress.transactions.load(); + final txns = storedAddress.transactions.toList(); + await mainDB.isar.addresses.delete(storedAddress.id); + await mainDB.isar.addresses.put(address); + address.transactions.addAll(txns); + await address.transactions.save(); + } + }); + } catch (e) { + throw MainDBException("failed _updateAddressInDB: $address", e); + } + } + /// Only index 0 is currently used in stack wallet. Future
_generateAndStoreReceivingAddressForIndex(int index) async { + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } + // Since only 0 is a valid index in stack wallet at this time, lets just // throw is not zero if (index != 0) { @@ -628,29 +649,10 @@ class EpiccashWallet extends Bip39Wallet { } final epicBoxConfig = await getEpicBoxConfig(); - final address = await thisWalletAddress(index, epicBoxConfig); - - if (info.cachedReceivingAddress != address.value) { - await info.updateReceivingAddress( - newAddress: address.value, - isar: mainDB.isar, - ); - } - return address; - } - - Future
thisWalletAddress( - int index, - EpicBoxConfigModel epicboxConfig, - ) async { - if (_wallet == null) { - throw Exception('Wallet not opened. Call open() first.'); - } - final walletAddress = await libEpic.getAddressInfo( wallet: _wallet!, index: index, - epicboxConfig: epicboxConfig.toString(), + epicboxConfig: epicBoxConfig.toString(), ); Logging.instance.d("WALLET_ADDRESS_IS $walletAddress"); @@ -664,7 +666,14 @@ class EpiccashWallet extends Bip39Wallet { subType: AddressSubType.receiving, publicKey: [], // ?? ); - await mainDB.updateOrPutAddresses([address]); + await _updateAddressInDB(address); + if (info.cachedReceivingAddress != address.value) { + await info.updateReceivingAddress( + newAddress: address.value, + isar: mainDB.isar, + ); + } + return address; } @@ -847,7 +856,7 @@ class EpiccashWallet extends Bip39Wallet { value: password, ); await secureStorageInterface.write( - key: '${walletId}_epicboxConfig', + key: '${walletId}_epicboxConfigNewNewNew', value: epicboxConfig.toString(), ); @@ -904,6 +913,9 @@ class EpiccashWallet extends Bip39Wallet { await updateNode(); + // ensure address is up to date with epic box uri + await _generateAndStoreReceivingAddressForIndex(0); + await _listenToEpicbox(); } catch (e, s) { // do nothing, still allow user into wallet @@ -1114,6 +1126,10 @@ class EpiccashWallet extends Bip39Wallet { epicBoxConfig: epicboxConfig.toString(), ); + await _generateAndStoreReceivingAddressForIndex( + info.epicData?.receivingIndex ?? 0, + ); + await _listenToEpicbox(); highestPercent = 0; @@ -1135,7 +1151,7 @@ class EpiccashWallet extends Bip39Wallet { ); await secureStorageInterface.write( - key: '${walletId}_epicboxConfig', + key: '${walletId}_epicboxConfigNewNewNew', value: epicboxConfig.toString(), ); @@ -1215,10 +1231,6 @@ class EpiccashWallet extends Bip39Wallet { // await epicUpdateCreationHeight(await chainHeight); // } - // this will always be zero???? - final int curAdd = await _getCurrentIndex(); - await _generateAndStoreReceivingAddressForIndex(curAdd); - if (_wallet == null) { throw Exception('Wallet not opened. Call open() first.'); } @@ -1397,7 +1409,7 @@ class EpiccashWallet extends Bip39Wallet { OutputV2 output = OutputV2.isarCantDoRequiredInDefaultConstructor( scriptPubKeyHex: "00", valueStringSats: credit.toString(), - addresses: [if (addressFrom != null) addressFrom], + addresses: [if (addressTo != null) addressTo], walletOwns: true, ); final InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( @@ -1405,7 +1417,7 @@ class EpiccashWallet extends Bip39Wallet { scriptSigAsm: null, sequence: null, outpoint: null, - addresses: [if (addressTo != null) addressTo], + addresses: [if (addressFrom != null) addressFrom], valueStringSats: debit.toString(), witness: null, innerRedeemScriptAsm: null, diff --git a/lib/widgets/epicbox_card.dart b/lib/widgets/epicbox_card.dart new file mode 100644 index 000000000..ca80683e8 --- /dev/null +++ b/lib/widgets/epicbox_card.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../providers/global/node_service_provider.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/assets.dart'; +import '../utilities/default_epicboxes.dart'; +import '../utilities/test_epicbox_server_connection.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import 'custom_buttons/blue_text_button.dart'; +import 'expandable.dart'; +import 'rounded_white_container.dart'; + +class EpicBoxCard extends ConsumerStatefulWidget { + const EpicBoxCard({ + super.key, + required this.epicBoxId, + required this.onConnect, + required this.onEdit, + this.testOnInit = false, + }); + + final String epicBoxId; + final VoidCallback onConnect; + final VoidCallback onEdit; + final bool testOnInit; + + @override + ConsumerState createState() => _EpicBoxCardState(); +} + +class _EpicBoxCardState extends ConsumerState { + bool _advancedIsExpanded = false; + bool _testing = false; + bool? _testResult; + + @override + void initState() { + super.initState(); + if (widget.testOnInit) { + WidgetsBinding.instance.addPostFrameCallback((_) => _testConnection()); + } + } + + @override + void didUpdateWidget(EpicBoxCard oldWidget) { + super.didUpdateWidget(oldWidget); + // Auto-test when testOnInit changes from false to true + if (widget.testOnInit && !oldWidget.testOnInit && _testResult == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _testConnection(); + }); + } + } + + Future _testConnection() async { + final epicBox = + ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: widget.epicBoxId) ?? + DefaultEpicBoxes.all.firstWhere((e) => e.id == widget.epicBoxId); + + setState(() { + _testing = true; + _testResult = null; + }); + + final data = EpicBoxFormData() + ..host = epicBox.host + ..port = epicBox.port ?? 443 + ..useSSL = epicBox.useSSL; + + final result = await testEpicBoxServerConnection(data) != null; + + if (mounted) { + setState(() { + _testing = false; + _testResult = result; + }); + } + } + + @override + Widget build(BuildContext context) { + final epicBox = + ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getEpicBoxById(id: widget.epicBoxId), + ), + ) ?? + DefaultEpicBoxes.all.firstWhere((e) => e.id == widget.epicBoxId); + + final primaryEpicBox = ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getPrimaryEpicBox(), + ), + ); + + final isPrimary = primaryEpicBox?.id == epicBox.id; + final isDesktop = Util.isDesktop; + + String status; + Color? statusColor; + if (_testing) { + status = "Testing..."; + } else if (_testResult == true) { + status = isPrimary ? "Connected" : "Reachable"; + statusColor = Theme.of( + context, + ).extension()!.accentColorGreen; + } else if (_testResult == false) { + status = "Unreachable"; + statusColor = Theme.of(context).extension()!.accentColorRed; + } else { + status = isPrimary ? "Selected" : ""; + if (isPrimary) { + statusColor = Theme.of( + context, + ).extension()!.accentColorBlue; + } + } + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + borderColor: isDesktop + ? Theme.of(context).extension()!.background + : null, + child: Expandable( + onExpandChanged: (state) { + setState(() { + _advancedIsExpanded = state == ExpandableState.expanded; + }); + }, + header: Padding( + padding: EdgeInsets.all(isDesktop ? 16 : 12), + child: Row( + children: [ + Container( + width: isDesktop ? 40 : 24, + height: isDesktop ? 40 : 24, + decoration: BoxDecoration( + color: epicBox.isDefault + ? Theme.of( + context, + ).extension()!.buttonBackSecondary + : Theme.of(context) + .extension()! + .infoItemIcons + .withOpacity(0.2), + borderRadius: BorderRadius.circular(100), + ), + child: Center( + child: SvgPicture.asset( + Assets.svg.node, + height: isDesktop ? 18 : 11, + width: isDesktop ? 20 : 14, + color: epicBox.isDefault + ? Theme.of( + context, + ).extension()!.accentColorDark + : Theme.of( + context, + ).extension()!.infoItemIcons, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(epicBox.name, style: STextStyles.titleBold12(context)), + const SizedBox(height: 2), + Text( + "${epicBox.host}:${epicBox.port ?? 443}", + style: STextStyles.label(context), + ), + ], + ), + ), + Text( + status, + style: STextStyles.label(context).copyWith(color: statusColor), + ), + const SizedBox(width: 12), + SvgPicture.asset( + _advancedIsExpanded + ? Assets.svg.chevronUp + : Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ], + ), + ), + body: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + children: [ + const SizedBox(width: 66), + CustomTextButton( + text: "Test", + enabled: !_testing, + onTap: _testConnection, + ), + const SizedBox(width: 48), + CustomTextButton( + text: "Connect", + enabled: !isPrimary, + onTap: widget.onConnect, + ), + const SizedBox(width: 48), + if (!epicBox.isDefault) + CustomTextButton(text: "Edit", onTap: widget.onEdit), + ], + ), + ), + ), + ); + } +}