Skip to content

Conversation

@grunch
Copy link
Member

@grunch grunch commented Feb 10, 2026

Summary by CodeRabbit

  • New Features

    • Modal node selector to browse/switch trusted and custom Mostro nodes; add/remove custom nodes; avatars and active indicators
    • Add Custom Node dialog accepting hex or npub input with inline validation and non-blocking metadata fetch
    • Trusted badge and improved node avatar rendering in Settings and selector
  • Bug Fixes

    • Prevent deleting the active node; debounce rapid taps during node switches
    • Migration cleans conflicting custom nodes and persists custom node metadata; display name shows full pubkey when unnamed
  • Documentation

    • Phase 3 completed; English/Spanish/Italian localization added for node management UI and messages

Filter out custom nodes whose pubkey overlaps with trusted nodes during
init(). This handles the case where a trusted node pubkey was persisted
as a custom node before the trusted nodes registry existed. The cleaned
list is persisted back to disk as a one-time migration.
Add bottom sheet node selector with trusted/custom sections, node
avatars with kind 0 metadata, trusted badges, and custom node
management dialog. Integrate selector into settings screen replacing
the old pubkey text field. Update localization strings for en/es/it.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 10, 2026

Walkthrough

Adds a modal bottom-sheet Mostro node selector, an Add Custom Node dialog, avatar and trusted-badge widgets; introduces hex pubkey validation on MostroNode; notifier migration to remove custom/trusted overlaps and persist metadata; refactors Settings to use node-centric selection and updates tests and localization.

Changes

Cohort / File(s) Summary
Node Management Widgets
lib/features/mostro/widgets/mostro_node_selector.dart, lib/features/mostro/widgets/add_custom_node_dialog.dart, lib/features/mostro/widgets/mostro_node_avatar.dart, lib/features/mostro/widgets/trusted_badge.dart
Added MostroNodeSelector bottom-sheet (trusted/custom sections, add/delete, switching debounce, confirmation flows), AddCustomNodeDialog (npub→hex conversion, validation, duplicate checks, fire-and-forget metadata fetch), MostroNodeAvatar (network image + NymAvatar fallback), and TrustedBadge UI.
Node Model & Notifier
lib/features/mostro/mostro_node.dart, lib/features/mostro/mostro_nodes_notifier.dart
Added hexPubkeyRegex and isValidHexPubkey() on MostroNode; displayName now falls back to full pubkey (removed truncatedPubkey); notifier adds one-time migration to drop custom nodes overlapping trusted nodes, delegates validation to MostroNode, makes customNodes mutable, and persists node metadata.
Settings Screen Refactor
lib/features/settings/settings_screen.dart
Replaced text-input pubkey flow with node-centric UI: shows selected node (avatar/name/pubkey/trusted badge) and opens MostroNodeSelector on tap; removed legacy pubkey controllers, conversion, debounce, and restore-trigger UI logic.
Localization
lib/l10n/intl_en.arb, lib/l10n/intl_es.arb, lib/l10n/intl_it.arb
Added ~25 localization keys per language for node selection UI, add/delete dialogs, validation messages, switching feedback, confirmations, and placeholders.
Restore subsystem
lib/features/restore/restore_manager.dart, lib/features/restore/restore_progress_notifier.dart
Changed initRestoreProcess() to return Future<bool> with success flag; replaced implicit auto-hide with cancellable Timer in restore progress notifier and added cancellation on hide/dispose.
Tests
test/features/mostro/mostro_node_test.dart
Updated expectations: displayName uses full pubkey when name is absent; removed truncatedPubkey-related tests; updated toString assertions.

Sequence Diagram

sequenceDiagram
    actor User
    participant Settings as "SettingsScreen"
    participant Selector as "MostroNodeSelector"
    participant Dialog as "AddCustomNodeDialog"
    participant Notifier as "MostroNodesNotifier"
    participant Restore as "RestoreService"
    participant Meta as "MetadataService"

    User->>Settings: tap Mostro card
    Settings->>Selector: show()
    Selector->>User: display trusted + custom nodes

    User->>Selector: select node
    Selector->>Notifier: updateSelectedNode(pubkey)
    Notifier->>Restore: trigger restore flow
    Restore-->>Notifier: restore complete
    Notifier-->>Selector: confirm selection
    Selector->>Settings: close()

    User->>Selector: tap Add Custom Node
    Selector->>Dialog: show()
    Dialog->>User: collect pubkey & name
    User->>Dialog: submit
    Dialog->>Dialog: validate (hex/npub, duplicates)
    Dialog->>Notifier: addCustomNode(pubkey, name)
    Notifier->>Meta: fetchNodeMetadata(pubkey) [fire-and-forget]
    Dialog->>User: show success snackbar
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • AndreaDiazCorreia
  • BraCR10
  • Catrya

Poem

🐰 Hopping through keys at dusk's soft glow,

I pick a node where friendly avatars show,
Trusted badges shimmer, dialogs say "done",
I add a key and metadata runs,
A tiny rabbit cheeps — switching's begun!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'Phase 3 UI mostrod select' is directly related to the changeset, referencing Phase 3 UI completion and Mostro node selector implementation as documented in MULTI_MOSTRO_SUPPORT.md and the actual code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch phase-3-ui-mostrod-select

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@docs/MULTI_MOSTRO_SUPPORT.md`:
- Around line 128-139: Update the fenced diagram block to include a language
specifier by changing the opening fence from ``` to ```text so markdownlint
MD040 is satisfied; modify the block that documents the Settings Screen flow
(references: _buildMostroCard, MostroNodeSelector.show, MostroNodeSelector,
_buildNodeItem, AddCustomNodeDialog.show) to begin with ```text and keep the
diagram content unchanged.

In `@lib/features/mostro/widgets/add_custom_node_dialog.dart`:
- Around line 162-199: Normalize the hex pubkey string immediately after
conversion so duplicate/trusted checks are case-insensitive: in the block that
sets hexPubkey from _convertToHex(input), call a consistent normalization (e.g.,
hexPubkey = hexPubkey.toLowerCase()) and then use that normalized hexPubkey for
MostroNode.isValidHexPubkey, the nodes.any duplicate check
(mostroNodesProvider), and when calling
ref.read(mostroNodesProvider.notifier).addCustomNode so stored pubkeys are
normalized and case variants cannot bypass checks.

In `@lib/features/mostro/widgets/mostro_node_avatar.dart`:
- Around line 39-44: The fallback and no-picture branches pass size / 2 to
NymAvatar causing a smaller avatar than the Image.network; update both
occurrences (the errorBuilder fallback and the final return) to pass size (not
size / 2) into NymAvatar so NymAvatar(pubkeyHex: node.pubkey, size: size) is
used consistently.

In `@lib/features/mostro/widgets/mostro_node_selector.dart`:
- Around line 381-389: When removeCustomNode returns false the dialog closes
with no feedback; update the block that checks `removed` (the result of
`notifier.removeCustomNode(node.pubkey)`) to show an error snackbar in the else
branch using `SnackBarHelper.showTopSnackBar` (e.g.
`localizations.nodeRemoveFailed` or a generic failure message), and guard that
call with the same `mounted` check as the success branch; keep the existing
success snackbar (`localizations.nodeRemovedSuccess`) unchanged and ensure the
new branch only triggers when `removed` is false.
- Around line 328-333: Replace the raw exception text shown to users in the
SnackBar: in the call to SnackBarHelper.showTopSnackBarAsync (the invocation
using messenger, mediaQuery.size.height and mediaQuery.padding.top), stop
passing e.toString() as message and instead pass a user-friendly localized
string (e.g., AppLocalizations.of(context).errorSwitchingNode). Add the new
errorSwitchingNode key to the ARB localization files with appropriate
translations and use that key here so internal error details are not exposed to
the user.
🧹 Nitpick comments (3)
lib/features/settings/settings_screen.dart (1)

601-605: Consider using the watched value directly instead of the watch+read pattern.

ref.watch(mostroNodesProvider) subscribes for rebuilds but the return value is discarded; selectedNode is then read imperatively from the notifier. If mostroNodesProvider already exposes the node list as its state, you could derive selectedNode from it (or from a dedicated selectedNodeProvider) to keep the data flow purely declarative.

That said, this works correctly as-is—just a Riverpod idiom nit.

lib/features/mostro/widgets/mostro_node_selector.dart (2)

264-276: Small touch target on the delete button.

BoxConstraints() with EdgeInsets.all(4) padding yields a hit area well below the recommended 48×48 dp minimum. Consider using visualDensity or a SizedBox wrapper to ensure the target is large enough, while keeping the icon visually compact.


303-309: Silently swallowing restore errors may hide real issues.

The inner catch (_) discards any restore failure without logging. Even if restore errors are non-fatal, a logger.warning(...) here would aid debugging when node switches behave unexpectedly.

Proposed fix
+      import 'package:mostro_mobile/services/logger_service.dart';
       ...
         try {
           final restoreService = ref.read(restoreServiceProvider);
           await restoreService.initRestoreProcess();
-        } catch (_) {
-          // Ignore errors during restore
+        } catch (e) {
+          logger.w('Restore after node switch failed: $e');
         }

As per coding guidelines, always use the pre-configured singleton logger instance via import 'package:mostro_mobile/services/logger_service.dart'.

updateNodeMetadata() only updated in-memory state without calling
_saveCustomNodes(), causing metadata (name, picture, about) to be
lost on app restart. Also removes truncatedPubkey helper in favor
of ellipsis overflow, shows node about text in selector, and fixes
test count in docs.
Added .toLowerCase() at line 164 right after _convertToHex(input),
so hexPubkey is normalized before it's used for validation,
duplicate checking, and addCustomNode(). This prevents
case variants from bypassing existing checks.
@BraCR10
Copy link
Member

BraCR10 commented Feb 10, 2026

Hello,

When tapping Cancel or Add in the widget used to add nodes, an exception is thrown, However the node is added,it is necessary to close and reopen the app..

Below are the screenshots and the full error log for reference.

══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building RawGestureDetector(state:
RawGestureDetectorState#dfee0(gestures: [tap, long press, tap and horizontal drag],
excludeFromSemantics: true, behavior: translucent)):
A TextEditingController was used after being disposed.
Once you have called dispose() on a TextEditingController, it can no longer be used.

The relevant error-causing widget was:
  TextField
  TextField:file:///D:/MostroWorkplace/mobile/lib/features/mostro/widgets/add_custom_node_dialog.dart:64:32

When the exception was thrown, this was the stack:
#0      ChangeNotifier.debugAssertNotDisposed.<anonymous closure> (package:flutter/src/foundation/change_notifier.dart:182:9)
#1      ChangeNotifier.debugAssertNotDisposed (package:flutter/src/foundation/change_notifier.dart:189:6)
#2      ChangeNotifier.addListener (package:flutter/src/foundation/change_notifier.dart:271:27)
#3      _MergingListenable.addListener (package:flutter/src/foundation/change_notifier.dart:503:14)
#4      _AnimatedState.didUpdateWidget (package:flutter/src/widgets/transitions.dart:119:25)
#5      StatefulElement.update (package:flutter/src/widgets/framework.dart:5893:55)
#6      Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#7      SingleChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:7025:14)
#8      Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#9      ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5747:16)
#10     StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5884:11)
#11     Element.rebuild (package:flutter/src/widgets/framework.dart:5435:7)
#12     StatefulElement.update (package:flutter/src/widgets/framework.dart:5909:5)
#13     Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#14     ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5747:16)
#15     StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5884:11)
#16     Element.rebuild (package:flutter/src/widgets/framework.dart:5435:7)
#17     StatefulElement.update (package:flutter/src/widgets/framework.dart:5909:5)
#18     Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#19     SingleChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:7025:14)
#20     Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#21     ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5747:16)
#22     StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5884:11)
#23     Element.rebuild (package:flutter/src/widgets/framework.dart:5435:7)
#24     StatefulElement.update (package:flutter/src/widgets/framework.dart:5909:5)
#25     Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#26     SingleChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:7025:14)
#27     Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#28     SingleChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:7025:14)
#29     Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#30     SingleChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:7025:14)
#31     Element.updateChild (package:flutter/src/widgets/framework.dart:3982:15)
#32     ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5747:16)
#33     StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5884:11)
#34     Element.rebuild (package:flutter/src/widgets/framework.dart:5435:7)
#35     BuildScope._tryRebuild (package:flutter/src/widgets/framework.dart:2695:15)
#36     BuildScope._flushDirtyElements (package:flutter/src/widgets/framework.dart:2752:11)
#37     BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:3056:18)
#38     WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1259:21)
#39     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:495:5)
#40     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1434:15)
#41     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1347:9)
#42     SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1200:5)
#43     _invoke (dart:ui/hooks.dart:330:13)
#44     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:444:5)
#45     _drawFrame (dart:ui/hooks.dart:302:31)

════════════════════════════════════════════════════════════════════════════════════════════════════

Another exception was thrown: A RenderFlex overflowed by 99443 pixels on the bottom.
I/ImeTracker(12000): network.mostro.app:88c82f5a: onRequestHide at ORIGIN_CLIENT reason HIDE_SOFT_INPUT fromUser false
D/InsetsController(12000): hide(ime(), fromIme=false)
D/InsetsController(12000): Setting requestedVisibleTypes to -9 (was -1)
Another exception was thrown: 'package:flutter/src/widgets/framework.dart': Failed assertion: line 6171 pos 14: '_dependents.isEmpty': is not true.
Another exception was thrown: Tried to build dirty widget in the wrong build scope.
I/ImeTracker(12000): network.mostro.app:ba29bddc: onCancelled at PHASE_CLIENT_ON_CONTROLS_CHANGED
I/ImeTracker(12000): system_server:6449a38b: onCancelled at PHASE_CLIENT_ON_CONTROLS_CHANGED
W/WindowOnBackDispatcher(12000): sendCancelIfRunning: isInProgress=false callback=android.view.ImeBackAnimationController@9480173
Another exception was thrown: Tried to build dirty widget in the wrong build scope. 

Copy link
Member

@Catrya Catrya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first time I open the app, the Mostro logo appears correctly, but after an error when adding a new mostro, when I close and reopen the app, the default mostro logo is missing.
Image

@grunch
Copy link
Member Author

grunch commented Feb 11, 2026

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@lib/features/mostro/widgets/add_custom_node_dialog.dart`:
- Line 26: The dialog allows dismissal while awaiting the async addCustomNode
handler which can dispose controllers and cause setState (in the dialog's state)
or Navigator.pop to run on an unmounted tree; to fix, set barrierDismissible:
false on the showDialog call (replace barrierDismissible: true) to prevent
outside taps during the async operation, or alternatively add a mounted check
after the await in the onPressed handler (e.g., before calling setState or
Navigator.pop) to bail out if the dialog was dismissed; update the code paths in
the addCustomNode onPressed handler and any setState/Navigator.pop calls to use
this guard if you choose the mounted-check approach.
- Around line 23-248: The controllers (pubkeyController, nameController) are
being disposed in the finally after showDialog, which can run while the dialog's
exit animation is still active and cause "used after disposed" crashes; extract
the dialog UI into a standalone StatefulWidget (e.g.,
AddCustomNodeDialogStateful) that creates and owns pubkeyController and
nameController in its State and disposes them in State.dispose(), then change
the showDialog builder to return that StatefulWidget (leave logic like
navigator.pop(), _convertToHex, MostroNode.isValidHexPubkey, and
notifier.addCustomNode unchanged).

In `@lib/l10n/intl_en.arb`:
- Around line 1366-1402: Add the 27 missing localization entries from the
English ARB into the Italian ARB (intl_it.arb): include keys such as
selectMostroNode, mostroNodeDescription, trustedNodesSection,
customNodesSection, addCustomNode, addCustomNodeTitle, enterNodePubkey,
enterNodeName, pubkeyHint, nodeNameHint, nodeAlreadyExists, invalidPubkeyFormat,
nodeAddedSuccess, nodeRemovedSuccess, nodeRemoveFailed, cannotRemoveActiveNode,
deleteCustomNodeTitle, deleteCustomNodeMessage, deleteCustomNodeConfirm,
deleteCustomNodeCancel, switchingToNode, nodeSwitchedSuccess,
errorSwitchingNode, trusted, noCustomNodesYet, pubkeyRequired, tapToSelectNode;
also add the corresponding metadata blocks for placeholder keys (the
"@switchingToNode" and "@nodeSwitchedSuccess" objects with the "nodeName"
placeholder of type "String") so the Italian file mirrors the structure of
intl_en.arb.

…ctive

  Problem: TextEditingControllers were created in the static show() method and disposed in a finally block. Since showDialog's future resolves when Navigator.pop() is called
  (before the exit animation finishes), the controllers got disposed while the widget tree still referenced them, causing "used after disposed" crashes.

  Fix: Extracted the dialog body into a private _AddCustomNodeDialogContent StatefulWidget that:
  - Owns pubkeyController and nameController (created in initState())
  - Owns errorMessage as local state (replacing the StatefulBuilder)
  - Disposes both controllers in State.dispose() — safe because Flutter calls dispose after the animation completes

  The show() method is now simplified to just capture parent context values and call showDialog with the new widget. The try/finally block is removed entirely.
When a user switches to a different Mostro node, the restore process
clears all existing data before contacting the new node. If the new
node is offline, the app was left in a broken state with no data and
settings pointing to an unreachable node.

Now if the restore fails, the app automatically reverts to the previous
node and re-restores its data. The user sees an error snackbar and the
bottom sheet stays open for them to pick a different node.

- Make auto-hide timers in RestoreProgressNotifier cancellable to
  prevent stale timers from hiding the overlay during a revert restore
- Return success/failure from initRestoreProcess() so callers can react
- Handle restore failure in MostroNodeSelector with automatic revert
- Add nodeNotRespondingReverted localization key (en/es/it)
@grunch
Copy link
Member Author

grunch commented Feb 11, 2026

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@grunch grunch requested a review from Catrya February 11, 2026 14:51
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@lib/features/mostro/widgets/mostro_node_selector.dart`:
- Around line 297-368: In _onNodeTap, guard against the widget being unmounted
before calling navigator.pop() and showing snackbars: after the async operations
that precede navigator.pop() (e.g., after await notifier.selectNode and any
restore flow), check if (mounted) before calling navigator.pop() and before
calling SnackBarHelper.showTopSnackBarAsync (using the captured messenger,
mediaQuery, and localizations); if not mounted, return early to avoid popping or
using an unmounted context and avoid calling setState unless mounted. Ensure
this mounted check covers both the successful-switch path (navigator.pop +
success snackbar) and the catch/error snackbar path.

In `@lib/features/restore/restore_manager.dart`:
- Around line 719-721: The catch block currently calls
ref.read(restoreProgressProvider.notifier).showError('') which passes an empty
message to the UI; replace that empty string with a user-facing, non-technical
message (e.g., a localized "Restore failed" string) while leaving the existing
logger.e('Restore: error during restore process', error: e, stackTrace: stack)
intact so full error details remain in logs; specifically update the call to
ref.read(restoreProgressProvider.notifier).showError(...) to use your app's
localization helper (or a hardcoded friendly message if localization isn't
available) instead of ''.
- Around line 684-685: The code calls progress.completeRestore() twice when
ordersMap is empty: once just before returning true in the early-exit
(progress.completeRestore()) and again from the finally block (which also runs
on normal completion), causing the auto-hide timer to restart; fix this by
removing the early progress.completeRestore() call in the branch that checks
ordersMap.isEmpty and just return true there so the finally block's
completeRestore() handles cleanup, ensuring no duplicate calls to
progress.completeRestore().
🧹 Nitpick comments (1)
lib/features/restore/restore_progress_notifier.dart (1)

62-68: Consider extracting the duplicated auto-hide scheduling.

Lines 62–68 and 80–86 are identical. A small helper would reduce duplication.

♻️ Proposed refactor
+  void _scheduleAutoHide() {
+    _cancelAutoHideTimer();
+    _autoHideTimer = Timer(const Duration(seconds: 3), () {
+      if (mounted) {
+        hide();
+      }
+    });
+  }
+
   void completeRestore() {
     logger.i('Restore completed successfully');
     _cancelTimeoutTimer();
 
     state = state.copyWith(
       step: RestoreStep.completed,
     );
 
-    // Auto-hide after 3 seconds (cancellable)
-    _cancelAutoHideTimer();
-    _autoHideTimer = Timer(const Duration(seconds: 3), () {
-      if (mounted) {
-        hide();
-      }
-    });
+    _scheduleAutoHide();
   }
 
   void showError(String message) {
     logger.w('Restore error: $message');
     _cancelTimeoutTimer();
 
     state = state.copyWith(
       step: RestoreStep.error,
       errorMessage: message,
     );
 
-    // Auto-hide after 3 seconds (cancellable)
-    _cancelAutoHideTimer();
-    _autoHideTimer = Timer(const Duration(seconds: 3), () {
-      if (mounted) {
-        hide();
-      }
-    });
+    _scheduleAutoHide();
   }

Also applies to: 80-86

Comment on lines +297 to +368
Future<void> _onNodeTap(MostroNode node) async {
setState(() {
_isSwitching = true;
_switchingPubkey = node.pubkey;
});

// Capture context values before async
final messenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context);
final localizations = S.of(context)!;
final navigator = Navigator.of(context);

try {
final notifier = ref.read(mostroNodesProvider.notifier);
final oldPubkey = ref.read(settingsProvider).mostroPublicKey;

await notifier.selectNode(node.pubkey);

if (oldPubkey != node.pubkey) {
final restoreService = ref.read(restoreServiceProvider);
final success = await restoreService.initRestoreProcess();

if (!success) {
// Hide error overlay from failed restore
ref.read(restoreProgressProvider.notifier).hide();

// Revert settings to old node
await notifier.selectNode(oldPubkey);

// Re-restore old node's data (since _clearAll already wiped it)
await restoreService.initRestoreProcess();

if (mounted) {
setState(() {
_isSwitching = false;
_switchingPubkey = null;
});
}

SnackBarHelper.showTopSnackBarAsync(
messenger: messenger,
screenHeight: mediaQuery.size.height,
statusBarHeight: mediaQuery.padding.top,
message: localizations.nodeNotRespondingReverted,
);
return;
}
}

navigator.pop();

SnackBarHelper.showTopSnackBarAsync(
messenger: messenger,
screenHeight: mediaQuery.size.height,
statusBarHeight: mediaQuery.padding.top,
message: localizations.nodeSwitchedSuccess(node.displayName),
backgroundColor: Colors.green,
);
} catch (e) {
if (mounted) {
setState(() {
_isSwitching = false;
_switchingPubkey = null;
});
}
SnackBarHelper.showTopSnackBarAsync(
messenger: messenger,
screenHeight: mediaQuery.size.height,
statusBarHeight: mediaQuery.padding.top,
message: localizations.errorSwitchingNode,
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the widget class definition and build method
rg -A 30 "class MostroNodeSelector" lib/features/mostro/widgets/mostro_node_selector.dart

Repository: MostroP2P/mobile

Length of output: 1077


🏁 Script executed:

# Check if there's WillPopScope or PopScope usage
rg -B 5 -A 10 "(WillPopScope|PopScope|barrierDismissible)" lib/features/mostro/widgets/mostro_node_selector.dart

Repository: MostroP2P/mobile

Length of output: 42


🏁 Script executed:

# Look for how _isSwitching is used in the build method
rg -B 2 -A 5 "_isSwitching" lib/features/mostro/widgets/mostro_node_selector.dart

Repository: MostroP2P/mobile

Length of output: 2064


🏁 Script executed:

# Find how this widget is displayed (showModalBottomSheet call)
rg -B 5 -A 10 "MostroNodeSelector\(\)" lib/

Repository: MostroP2P/mobile

Length of output: 1408


🏁 Script executed:

# Check for other showModalBottomSheet calls with barrierDismissible: false
rg "barrierDismissible.*false" lib/

Repository: MostroP2P/mobile

Length of output: 205


🏁 Script executed:

# Check if there are other async operations in bottom sheets with mounted checks before pop
rg -B 5 -A 3 "if.*mounted.*\).*\n.*navigator\.pop\(\)" lib/

Repository: MostroP2P/mobile

Length of output: 251


🏁 Script executed:

# Check for mounted checks before navigator.pop() with multiline
rg -U "if.*mounted.*navigator\.pop\(\)" lib/

Repository: MostroP2P/mobile

Length of output: 42


🏁 Script executed:

# Look at the relay_selector.dart to see if it has similar async operations
cat -n lib/features/relays/widgets/relay_selector.dart | head -100

Repository: MostroP2P/mobile

Length of output: 4079


🏁 Script executed:

# Check how relay_selector.dart handles async operations
rg -A 20 "barrierDismissible.*false" lib/features/relays/widgets/relay_selector.dart

Repository: MostroP2P/mobile

Length of output: 882


Guard navigator.pop() against unmounted context after async operations.

The bottom sheet defaults to barrierDismissible: true, allowing dismissal via swipe or outside tap during the async node switch. Without a mounted check, line 346's navigator.pop() could pop the underlying route if the sheet was already dismissed, and the success snackbar would execute on an unmounted context.

Suggested fix
-      navigator.pop();
-
-      SnackBarHelper.showTopSnackBarAsync(
+      if (!mounted) return;
+      navigator.pop();
+
+      SnackBarHelper.showTopSnackBarAsync(
         messenger: messenger,
         screenHeight: mediaQuery.size.height,
         statusBarHeight: mediaQuery.padding.top,
         message: localizations.nodeSwitchedSuccess(node.displayName),
         backgroundColor: Colors.green,
       );
🤖 Prompt for AI Agents
In `@lib/features/mostro/widgets/mostro_node_selector.dart` around lines 297 -
368, In _onNodeTap, guard against the widget being unmounted before calling
navigator.pop() and showing snackbars: after the async operations that precede
navigator.pop() (e.g., after await notifier.selectNode and any restore flow),
check if (mounted) before calling navigator.pop() and before calling
SnackBarHelper.showTopSnackBarAsync (using the captured messenger, mediaQuery,
and localizations); if not mounted, return early to avoid popping or using an
unmounted context and avoid calling setState unless mounted. Ensure this mounted
check covers both the successful-switch path (navigator.pop + success snackbar)
and the catch/error snackbar path.

Comment on lines 684 to +685
progress.completeRestore();
return;
return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Double completeRestore() call on the early-exit path.

When ordersMap is empty, completeRestore() is called at line 684, then return true triggers the finally block, which calls completeRestore() again at line 734 (since step is completed, not error). This restarts the auto-hide timer unnecessarily.

Either remove the call at line 684 (let finally handle it), or guard the finally call to also skip the completed step.

Proposed fix (option A: let finally handle it)
       if (ordersMap.isEmpty) {
         logger.w('Restore: no orders or disputes to restore');
         await _sendLastTradeIndexRequest();
         final lastTradeIndexEvent = await _waitForEvent(RestoreStage.gettingTradeIndex);
         final lastTradeIndexResponse = await _extractLastTradeIndex(lastTradeIndexEvent);
         final lastTradeIndex = lastTradeIndexResponse.tradeIndex;
         await keyManager.setCurrentKeyIndex(lastTradeIndex + 1);
-        progress.completeRestore();
+        success = true;
         return true;
       }
Proposed fix (option B: guard in finally)
       // Only call completeRestore if not in error state
       final currentState = ref.read(restoreProgressProvider);
-      if (currentState.step != RestoreStep.error) {
+      if (currentState.step != RestoreStep.error && currentState.step != RestoreStep.completed) {
         ref.read(restoreProgressProvider.notifier).completeRestore();
       }
🤖 Prompt for AI Agents
In `@lib/features/restore/restore_manager.dart` around lines 684 - 685, The code
calls progress.completeRestore() twice when ordersMap is empty: once just before
returning true in the early-exit (progress.completeRestore()) and again from the
finally block (which also runs on normal completion), causing the auto-hide
timer to restart; fix this by removing the early progress.completeRestore() call
in the branch that checks ordersMap.isEmpty and just return true there so the
finally block's completeRestore() handles cleanup, ensuring no duplicate calls
to progress.completeRestore().

Comment on lines 719 to 721
} catch (e, stack) {
logger.e('Restore: error during restore process', error: e, stackTrace: stack);
ref.read(restoreProgressProvider.notifier).showError('');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty error message passed to showError.

showError('') will propagate an empty string to the UI overlay's error message field. Consider using a generic user-friendly message instead (e.g., a localized "Restore failed" string), while keeping the raw exception in the logger call at line 720. Based on learnings, the team prefers to keep technical error details out of the UI while logging the full error via logger.e().

Suggested change
     } catch (e, stack) {
       logger.e('Restore: error during restore process', error: e, stackTrace: stack);
-      ref.read(restoreProgressProvider.notifier).showError('');
+      ref.read(restoreProgressProvider.notifier).showError('Restore failed');
     } finally {
🤖 Prompt for AI Agents
In `@lib/features/restore/restore_manager.dart` around lines 719 - 721, The catch
block currently calls ref.read(restoreProgressProvider.notifier).showError('')
which passes an empty message to the UI; replace that empty string with a
user-facing, non-technical message (e.g., a localized "Restore failed" string)
while leaving the existing logger.e('Restore: error during restore process',
error: e, stackTrace: stack) intact so full error details remain in logs;
specifically update the call to
ref.read(restoreProgressProvider.notifier).showError(...) to use your app's
localization helper (or a hardcoded friendly message if localization isn't
available) instead of ''.

Copy link
Collaborator

@mostronator mostronator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — Excellent Phase 3 Implementation

Great work on this Multi-Mostro UI phase. Detailed review:

Strengths

  • Clean architecture: Node selector as bottom sheet, custom node dialog as separate widget — good separation of concerns
  • Solid validation: npub/hex input validation with MostroNode.isValidHexPubkey(), duplicate detection, proper error states
  • Good UX: Non-blocking metadata fetch, debounced validation, loading spinners, prevent deleting active node
  • Complete i18n: All 3 languages (en/es/it) with proper placeholders
  • Smart migration: Auto-removing custom nodes that overlap with trusted nodes
  • Proper state management: RestoreManager now returns bool for success tracking, RestoreProgressNotifier properly cancels timers
  • Test maintenance: Removed truncatedPubkey tests that no longer apply, updated assertions

Minor observations (non-blocking)

  • MostroNodeAvatar uses Image.network directly — consider caching with CachedNetworkImage for better performance on slow connections
  • The _AddCustomNodeDialogContentState could benefit from a FormKey for more structured validation
  • Custom node metadata is persisted via _saveCustomNodes inside applyMetadata — this side effect could be more explicit

Overall a well-thought-out implementation that follows the project patterns. Ship it! 🚀

Copy link
Member

@Catrya Catrya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tACK

@grunch grunch merged commit ab557d4 into main Feb 11, 2026
2 checks passed
@grunch grunch deleted the phase-3-ui-mostrod-select branch February 11, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants