Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
in `.env` no longer has any effect on it. See
[User Roles](docs/user-roles.md) for details.

- **Welcome screen's experience-level picker is now three cards**: it matches
the Create Wallet / Import Wallet / Just Explore cards below it, with an
icon and a short description on each, and a highlighted border on the one
you're on. Same three levels, same behavior — just easier to compare at a
glance.

### Known Limitations

- **Single-key wallets — send and balance refresh not available**: importing a
Expand Down Expand Up @@ -288,3 +294,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Two settings changes made in quick succession could occasionally cause one
of them to be silently lost. Saving settings is now a single atomic step,
so no change is dropped.
- The onboarding Welcome screen on first launch no longer shows a red
"Disconnected — check your internet connection" banner before you have done
anything. On a fresh start there is no wallet yet and no sync has been
attempted, so that message was misleading; it now stays hidden until you
finish onboarding, and real connection problems are still reported afterwards.
10 changes: 6 additions & 4 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2299,10 +2299,12 @@ impl App for AppState {
// runs before the connection banner, which suppresses its redundant
// Connecting/Syncing text while the overlay is up.
let spv_overlaying = self.spv_block.is_overlaying();
if let Some(task) = self
.connection_banner
.update(ctx, &active_context, spv_overlaying)
{
if let Some(task) = self.connection_banner.update(
ctx,
&active_context,
spv_overlaying,
self.show_welcome_screen,
) {
self.handle_backend_task(task);
}
if let Some(task) = self.migration.dispatch_cold_start(&active_context) {
Expand Down
116 changes: 114 additions & 2 deletions src/app/reconcilers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};

use dash_sdk::dpp::dashcore::Network;
use eframe::egui;
Expand Down Expand Up @@ -226,19 +226,25 @@ impl SpvBlockReconciler {
}
}

/// Maximum startup interval for hiding pre-sync `Disconnected` during onboarding.
const ONBOARDING_DISCONNECTED_SUPPRESSION_DURATION: Duration = Duration::from_secs(120);

/// Reconciles the connection-status banner with the overall connection state.
pub(super) struct ConnectionBanner {
/// Previous state, to detect transitions. `None` forces re-evaluation.
previous_state: Option<OverallConnectionState>,
/// Handle to the current connection banner, if displayed.
handle: Option<BannerHandle>,
/// Start of the bounded onboarding-only `Disconnected` suppression window.
onboarding_started: Instant,
}

impl ConnectionBanner {
pub(super) fn new() -> Self {
Self {
previous_state: None,
handle: None,
onboarding_started: Instant::now(),
}
}

Expand All @@ -252,12 +258,15 @@ impl ConnectionBanner {

/// Update the banner for the current connection state. `spv_overlaying`
/// suppresses the redundant Connecting/Syncing copy while the SPV block is
/// up. Returns a [`BackendTask`] to dispatch on the first `Synced`.
/// up; `onboarding_active` suppresses the initial `Disconnected` banner while
/// the Welcome screen is showing (pre-sync, not a real failure). Returns a
/// [`BackendTask`] to dispatch on the first `Synced`.
pub(super) fn update(
&mut self,
ctx: &egui::Context,
app_context: &Arc<AppContext>,
spv_overlaying: bool,
onboarding_active: bool,
) -> Option<BackendTask> {
let connection_status = app_context.connection_status();
let current_state = connection_status.overall_state();
Expand All @@ -284,6 +293,21 @@ impl ConnectionBanner {
return None;
}

// The Welcome screen initially reads Disconnected before sync starts.
// Bound suppression so a stuck onboarding flag cannot hide real failures.
if onboarding_active
&& current_state == OverallConnectionState::Disconnected
&& self.onboarding_started.elapsed() < ONBOARDING_DISCONNECTED_SUPPRESSION_DURATION
Comment thread
lklimek marked this conversation as resolved.
Outdated
{
if let Some(handle) = self.handle.take() {
handle.clear();
}
// Invalidate rather than cache either state so suppression exit and
// recurring pre-suppression states both force reconciliation.
self.previous_state = None;
return None;
Comment thread
lklimek marked this conversation as resolved.
}

// Clear old banner on state transitions.
if state_changed && let Some(handle) = self.handle.take() {
handle.clear();
Expand Down Expand Up @@ -879,4 +903,92 @@ mod tests {
)),
);
}

/// Regression test for PR #907: a user who finishes onboarding while still
/// disconnected (`auto_start_spv` off, or genuinely offline) must still see
/// the real Disconnected banner. The suppression branch used to advance
/// `previous_state` to the suppressed value, which made `state_changed`
/// false on the very next frame — hitting the fast-path early return before
/// the onboarding check was even reached, and permanently hiding the
/// banner for the rest of the session.
#[test]
fn connection_banner_shows_disconnected_after_onboarding_ends_while_still_disconnected() {
let tmp = tempfile::tempdir().expect("tempdir");
let app_context = test_app_context(tmp.path());
// ConnectionStatus defaults to Disconnected — no sync has been asked for.
assert_eq!(
app_context.connection_status().overall_state(),
OverallConnectionState::Disconnected
);
let ctx = egui::Context::default();
let mut banner = ConnectionBanner::new();

// Frame 1: onboarding active, Disconnected — suppressed.
assert!(banner.update(&ctx, &app_context, false, true).is_none());
assert!(
banner.handle.is_none(),
"the Disconnected banner must stay hidden while onboarding is active"
);

// Frame 2: onboarding still active, state unchanged — still suppressed.
assert!(banner.update(&ctx, &app_context, false, true).is_none());
assert!(banner.handle.is_none());

// Frame 3: onboarding ends, connection is still Disconnected — the real
// banner must now appear even though `current_state` never changed.
assert!(banner.update(&ctx, &app_context, false, false).is_none());
assert!(
banner.handle.is_some(),
"a genuine Disconnected state must be reported once onboarding ends, \
even if the connection state itself never changed"
);
}

#[test]
fn connection_banner_restores_recurring_error_after_onboarding_suppression() {
let tmp = tempfile::tempdir().expect("tempdir");
let app_context = test_app_context(tmp.path());
let connection_status = app_context.connection_status();
let ctx = egui::Context::default();
let mut banner = ConnectionBanner::new();

connection_status.set_spv_status(crate::model::spv_status::SpvStatus::Error);
connection_status.refresh_state();
assert!(banner.update(&ctx, &app_context, false, true).is_none());
assert!(
banner.handle.is_some(),
"the first SPV error must be reported"
);

connection_status.set_spv_status(crate::model::spv_status::SpvStatus::Idle);
connection_status.refresh_state();
assert!(banner.update(&ctx, &app_context, false, true).is_none());
assert!(
banner.handle.is_none(),
"Disconnected must be suppressed while onboarding is active"
);

connection_status.set_spv_status(crate::model::spv_status::SpvStatus::Error);
connection_status.refresh_state();
assert!(banner.update(&ctx, &app_context, false, true).is_none());
assert!(
banner.handle.is_some(),
"a recurring SPV error must be restored after Disconnected suppression"
);
}

#[test]
fn connection_banner_reports_disconnected_after_onboarding_suppression_expires() {
let tmp = tempfile::tempdir().expect("tempdir");
let app_context = test_app_context(tmp.path());
let ctx = egui::Context::default();
let mut banner = ConnectionBanner::new();
banner.onboarding_started = Instant::now() - ONBOARDING_DISCONNECTED_SUPPRESSION_DURATION;

assert!(banner.update(&ctx, &app_context, false, true).is_none());
assert!(
banner.handle.is_some(),
"Disconnected must be reported after bounded onboarding suppression expires"
);
}
}
16 changes: 16 additions & 0 deletions src/model/user_role.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ impl UserRole {
}
}

/// Icon glyph shown with this role in the onboarding experience-level cards.
pub fn role_icon(self) -> &'static str {
match self {
UserRole::Everyday => "\u{1F464}", // 👤 bust in silhouette
UserRole::Power => "\u{1F6E0}", // 🛠 hammer and wrench
UserRole::Developer => "\u{1F4BB}", // 💻 laptop
}
}

/// Compact label for the always-visible interface-mode indicator in the nav
/// rail. `None` for the default role, which shows no indicator; the raised
/// roles use single-word forms of their [`label`](Self::label) that fit the
Expand Down Expand Up @@ -263,6 +272,13 @@ mod tests {
}
}

#[test]
fn role_icons_match_each_experience_level() {
assert_eq!(UserRole::Everyday.role_icon(), "\u{1F464}");
assert_eq!(UserRole::Power.role_icon(), "\u{1F6E0}");
assert_eq!(UserRole::Developer.role_icon(), "\u{1F4BB}");
}

/// The nav-rail indicator is hidden for the default role and shows a
/// distinct compact label for each raised role. The Power/Developer labels
/// must differ — collapsing them is the defect that made switching between
Expand Down
88 changes: 76 additions & 12 deletions src/ui/welcome_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::ui::components::message_banner::MessageBanner;
use crate::ui::components::styled::island_central_panel;
use crate::ui::theme::{DashColors, Shadow, Shape, Spacing};
use crate::ui::{MessageType, RootScreenType, ScreenType};
use egui::{RichText, ScrollArea, Vec2};
use egui::{RichText, ScrollArea, Vec2, WidgetInfo, WidgetType};
use std::sync::Arc;

/// The action the user wants to take after onboarding
Expand Down Expand Up @@ -109,10 +109,20 @@ impl WelcomeScreen {

let mut role = self.app_context.user_role();
let previous = role;
ui.horizontal(|ui| {
for option in [UserRole::Everyday, UserRole::Power, UserRole::Developer] {
ui.radio_value(&mut role, option, option.label());
}
let card_spacing = 16.0;
let card_visual_width = 170.0 + (Spacing::MD * 2.0) + 2.0;
let total_width = (card_visual_width * 3.0) + (card_spacing * 2.0);

ui.allocate_ui(Vec2::new(total_width, 140.0), |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = card_spacing;

for option in [UserRole::Everyday, UserRole::Power, UserRole::Developer] {
if self.render_role_card(ui, dark_mode, option, role == option) {
role = option;
}
}
});
});

if role != previous {
Expand All @@ -132,20 +142,74 @@ impl WelcomeScreen {
}

ui.add_space(6.0);
// Description of the selected mode, then a reversibility hint.
ui.label(
RichText::new(role.description())
.size(12.0)
.color(DashColors::text_secondary(dark_mode)),
);
ui.add_space(2.0);
ui.label(
RichText::new("You can change this later in Network Settings.")
.size(11.0)
.color(DashColors::text_secondary(dark_mode)),
);
}

fn render_role_card(
&self,
ui: &mut egui::Ui,
dark_mode: bool,
role: UserRole,
selected: bool,
) -> bool {
let fill = if selected {
DashColors::selected(dark_mode)
} else {
DashColors::background(dark_mode)
};
let stroke = if selected {
egui::Stroke::new(2.0, DashColors::DASH_BLUE)
} else {
egui::Stroke::new(1.0, DashColors::border_light(dark_mode))
};

let response = egui::Frame::new()
.fill(fill)
.stroke(stroke)
.corner_radius(Shape::RADIUS_LG)
.shadow(Shadow::small())
.inner_margin(Spacing::MD)
.show(ui, |ui| {
ui.set_min_size(Vec2::new(170.0, 100.0));
ui.set_max_size(Vec2::new(170.0, 100.0));

ui.vertical_centered(|ui| {
ui.label(RichText::new(role.role_icon()).size(24.0));

ui.add_space(5.0);

ui.label(
RichText::new(role.label())
.size(14.0)
.strong()
.color(DashColors::text_primary(dark_mode)),
);

ui.add_space(6.0);

ui.label(
RichText::new(role.description())
.size(11.0)
.color(DashColors::text_secondary(dark_mode)),
);
});
});

let response = response.response.interact(egui::Sense::click());
if response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response.widget_info(|| {
WidgetInfo::selected(WidgetType::RadioButton, true, selected, role.label())
});

response.clicked()
}

fn render_getting_started_section(&mut self, ui: &mut egui::Ui, dark_mode: bool) -> AppAction {
let card_spacing = 16.0;
// Card dimensions: 170 inner + 16*2 padding + ~2 border = ~204 per card
Expand Down
Loading
Loading