feat: add epicbox server management ui
What changed, and why it matters
This commit adds a new user-interface feature for managing 'Epic Box' servers in the Stack Wallet desktop app. It lets users add, edit, test, and connect to custom Epic Cash (EPIC) relay servers. The change is a feature addition, not a documented security fix. There are no obvious remote-code-execution or data-theft bugs visible in the diff, but it does introduce network calls to user-supplied hosts and stores custom server entries, which could in principle be abused by a malicious server or a social-engineering attack if input validation or TLS handling elsewhere is weak.
Treat as a normal feature commit. If auditing Epic Cash support, review the implementation of testEpicBoxServerConnection, updateEpicboxConfig, and NodeService.addEpicBox for TLS certificate validation, hostname verification, SSRF/redirect risks, and secure storage of server metadata. No immediate patch or incident response is indicated by this diff alone.
Security signals we found
New network-facing UI that accepts arbitrary host/port from the user
Save-allowed-even-if-unreachable flow could let a malicious actor trick a user into storing a hostile server
SSL toggle is user-controlled; no certificate-pinning or hostname-verification logic visible
No input sanitization for host field shown in diff
No vendor statement of security relevance in commit or supplied references
Evidence from the diff
The patch introduces UI components (AddEditEpicBoxView, DesktopManageEpicBoxDialog, EpicBoxCard) and wires them into the wallet options menu for EpiccashWallet. It calls testEpicBoxServerConnection() against user-provided host/port/SSL settings, allows saving even when the server is unreachable, and persists EpicBoxServerModel objects via NodeService. The commit also adds updateDefaultEpicBoxes() initialization in main.dart. No security-relevant validation logic is shown in the diff; host input is free-form text and SSL is a boolean toggle. The actual network/TLS implementation is in referenced but unshown utilities.
Changed components
lib/main.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dartlib/pages_desktop_specific/settings/settings_menu/epicbox_settings/add_edit_epicbox_view.dartlib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dartlib/services/node_service.dartlib/widgets/epicbox_card.dartInspect captured patch +941 / −12
diff --git a/lib/main.dart b/lib/main.dart
index 0dd2e32..1d8f992 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -394,6 +394,7 @@ class _MaterialAppWithThemeState extends ConsumerState<MaterialAppWithTheme>
unawaited(ref.read(baseCurrenciesProvider).update());
await _nodeService.updateDefaults();
+ await _nodeService.updateDefaultEpicBoxes();
await _notificationsService.init(
nodeService: _nodeService,
tradesService: _tradesService,
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 4beb825..dc7fc8b 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<StackColors>()!
+ .textFieldActiveSearchIconLeft,
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Text(
+ _WalletOptions.epicBoxSettings.prettyName,
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.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 0000000..ce8aeaf
--- /dev/null
+++ b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/add_edit_epicbox_view.dart
@@ -0,0 +1,448 @@
+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/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,
+ });
+
+ final AddEditEpicBoxViewType viewType;
+ final String? epicBoxId;
+ final VoidCallback onSave;
+
+ @override
+ ConsumerState<AddEditEpicBoxView> createState() => _AddEditEpicBoxViewState();
+}
+
+class _AddEditEpicBoxViewState extends ConsumerState<AddEditEpicBoxView> {
+ 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<void> _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<void> _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<dynamic>(
+ 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();
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _nameController = TextEditingController();
+ _hostController = TextEditingController();
+ _portController = TextEditingController();
+
+ if (widget.epicBoxId != null) {
+ final epicBox = ref
+ .read(nodeServiceChangeNotifierProvider)
+ .getEpicBoxById(id: widget.epicBoxId!);
+ if (epicBox != null) {
+ _nameController.text = epicBox.name;
+ _hostController.text = epicBox.host;
+ _portController.text = (epicBox.port ?? 443).toString();
+ _useSSL = epicBox.useSSL ?? true;
+ port = epicBox.port ?? 443;
+ }
+ } else {
+ _portController.text = "443";
+ port = 443;
+ }
+ }
+
+ @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: 36),
+ 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 0000000..c904c25
--- /dev/null
+++ b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart
@@ -0,0 +1,214 @@
+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/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<DesktopManageEpicBoxDialog> createState() =>
+ _DesktopManageEpicBoxDialogState();
+}
+
+class _DesktopManageEpicBoxDialogState
+ extends ConsumerState<DesktopManageEpicBoxDialog> {
+ Future<void> _onConnect(String epicBoxId) async {
+ final epicBox = ref
+ .read(nodeServiceChangeNotifierProvider)
+ .getEpicBoxById(id: epicBoxId);
+
+ if (epicBox == null) return;
+
+ 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<void>(
+ context: context,
+ builder: (_) => AddEditEpicBoxView(
+ viewType: AddEditEpicBoxViewType.edit,
+ epicBoxId: epicBoxId,
+ onSave: () {},
+ ),
+ );
+ }
+
+ void _onAdd() {
+ showDialog<void>(
+ 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(),
+ ),
+ );
+
+ final defaultBoxes = epicBoxes.where((e) => e.isDefault).toList();
+ final customBoxes = epicBoxes.where((e) => !e.isDefault).toList();
+
+ return DesktopDialog(
+ maxHeight: null,
+ 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: 20, right: 20, bottom: 20),
+ child: SingleChildScrollView(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (defaultBoxes.isNotEmpty) ...[
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 8,
+ ),
+ child: Text(
+ "Default servers",
+ style: STextStyles.smallMed12(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textDark3,
+ ),
+ ),
+ ),
+ ...defaultBoxes.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,
+ ),
+ ),
+ ),
+ ],
+ if (customBoxes.isNotEmpty) ...[
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 8,
+ ),
+ child: Text(
+ "Custom servers",
+ style: STextStyles.smallMed12(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textDark3,
+ ),
+ ),
+ ),
+ ...customBoxes.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/services/node_service.dart b/lib/services/node_service.dart
index eeacbc1..ad38d8d 100644
--- a/lib/services/node_service.dart
+++ b/lib/services/node_service.dart
@@ -168,15 +168,14 @@ class NodeService extends ChangeNotifier {
}
List<NodeModel> getNodesFor(CryptoCurrency coin) {
- final list =
- DB.instance
- .values<NodeModel>(boxName: DB.boxNameNodeModels)
- .where(
- (e) =>
- e.coinName == coin.identifier &&
- !e.id.startsWith(DefaultNodes.defaultNodeIdPrefix),
- )
- .toList();
+ final list = DB.instance
+ .values<NodeModel>(boxName: DB.boxNameNodeModels)
+ .where(
+ (e) =>
+ e.coinName == coin.identifier &&
+ !e.id.startsWith(DefaultNodes.defaultNodeIdPrefix),
+ )
+ .toList();
// add default to end of list
list.addAll(
@@ -272,8 +271,10 @@ class NodeService extends ChangeNotifier {
bool enabled,
bool shouldNotifyListeners,
) async {
- final model =
- DB.instance.get<NodeModel>(boxName: DB.boxNameNodeModels, key: id)!;
+ final model = DB.instance.get<NodeModel>(
+ boxName: DB.boxNameNodeModels,
+ key: id,
+ )!;
await DB.instance.put<NodeModel>(
boxName: DB.boxNameNodeModels,
key: model.id,
diff --git a/lib/widgets/epicbox_card.dart b/lib/widgets/epicbox_card.dart
new file mode 100644
index 0000000..13a585b
--- /dev/null
+++ b/lib/widgets/epicbox_card.dart
@@ -0,0 +1,211 @@
+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/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<EpicBoxCard> createState() => _EpicBoxCardState();
+}
+
+class _EpicBoxCardState extends ConsumerState<EpicBoxCard> {
+ bool _advancedIsExpanded = false;
+ bool _testing = false;
+ bool? _testResult;
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.testOnInit) {
+ WidgetsBinding.instance.addPostFrameCallback((_) => _testConnection());
+ }
+ }
+
+ Future<void> _testConnection() async {
+ final epicBox = ref
+ .read(nodeServiceChangeNotifierProvider)
+ .getEpicBoxById(id: widget.epicBoxId);
+ if (epicBox == null) return;
+
+ 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),
+ ),
+ );
+
+ if (epicBox == null) {
+ return const SizedBox.shrink();
+ }
+
+ 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<StackColors>()!.accentColorGreen;
+ } else if (_testResult == false) {
+ status = "Unreachable";
+ statusColor = Theme.of(context).extension<StackColors>()!.accentColorRed;
+ } else {
+ status = isPrimary ? "Selected" : "";
+ if (isPrimary) {
+ statusColor = Theme.of(context).extension<StackColors>()!.accentColorBlue;
+ }
+ }
+
+ return RoundedWhiteContainer(
+ padding: const EdgeInsets.all(0),
+ borderColor: isDesktop
+ ? Theme.of(context).extension<StackColors>()!.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<StackColors>()!.buttonBackSecondary
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .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<StackColors>()!.accentColorDark
+ : Theme.of(
+ context,
+ ).extension<StackColors>()!.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<StackColors>()!.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: 24),
+ CustomTextButton(
+ text: "Connect",
+ enabled: !isPrimary,
+ onTap: widget.onConnect,
+ ),
+ const SizedBox(width: 48),
+ if (!epicBox.isDefault)
+ CustomTextButton(text: "Edit", onTap: widget.onEdit),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.