fission-onboarding 0.1.0

Guided in-app tours and spotlight onboarding for Fission applications
Documentation
use fission::prelude::*;

use crate::{
    OnboardingFlow, OnboardingStyle, card::OnboardingCard, focus_ring::SpotlightFocusRing,
};

#[derive(Clone, Debug, PartialEq)]
pub struct OnboardingLabels {
    pub progress: TextContent,
    pub back: TextContent,
    pub continue_label: TextContent,
    pub finish: TextContent,
    pub skip: TextContent,
    pub dialog_label: String,
}

impl Default for OnboardingLabels {
    fn default() -> Self {
        Self {
            progress: "Getting started".into(),
            back: "Back".into(),
            continue_label: "Continue".into(),
            finish: "Finish".into(),
            skip: "Skip tour".into(),
            dialog_label: "Guided tour".into(),
        }
    }
}

#[derive(Clone)]
pub struct OnboardingHost {
    pub id: String,
    pub flow: OnboardingFlow,
    pub current_step: usize,
    pub labels: OnboardingLabels,
    pub style: OnboardingStyle,
    pub busy: bool,
    pub continue_enabled: bool,
    pub contextual_action: Option<(TextContent, ActionEnvelope)>,
    pub contextual_status: Option<TextContent>,
    pub on_back: Option<ActionEnvelope>,
    pub on_continue: ActionEnvelope,
    pub on_skip: Option<ActionEnvelope>,
    pub on_complete: ActionEnvelope,
}

impl OnboardingHost {
    /// Start a tour over the application's live widget tree. The application
    /// owns step transitions and persistence; this widget only presents them.
    pub fn new(
        id: impl Into<String>,
        flow: OnboardingFlow,
        current_step: usize,
        on_continue: ActionEnvelope,
        on_complete: ActionEnvelope,
    ) -> Self {
        Self {
            id: id.into(),
            flow,
            current_step,
            labels: OnboardingLabels::default(),
            style: OnboardingStyle::default(),
            busy: false,
            continue_enabled: true,
            contextual_action: None,
            contextual_status: None,
            on_back: None,
            on_continue,
            on_skip: None,
            on_complete,
        }
    }
}

impl From<OnboardingHost> for Widget {
    fn from(host: OnboardingHost) -> Self {
        let (ctx, _) = fission::build::current::<()>();
        let Some(step) = host.flow.step(host.current_step).cloned() else {
            return Spacer::default().into();
        };

        let dim_panel = |suffix: &str| {
            GestureDetector {
                id: Some(WidgetId::explicit(&format!(
                    "{}-spotlight-{suffix}",
                    host.id
                ))),
                child: Container::new(Spacer::default())
                    .bg(host.style.backdrop)
                    .into(),
                on_tap: None,
                ..Default::default()
            }
            .into()
        };
        let focus_ring: Widget = SpotlightFocusRing {
            color: host.style.focus_ring,
            width: host.style.focus_ring_width,
        }
        .into();
        let overlay = Positioned {
            left: Some(0.0),
            top: Some(0.0),
            right: Some(0.0),
            bottom: Some(0.0),
            child: Some(
                Spotlight {
                    anchor: step.anchor,
                    padding: host.style.spotlight_padding,
                    children: [
                        dim_panel("top"),
                        dim_panel("bottom"),
                        dim_panel("left"),
                        dim_panel("right"),
                        focus_ring,
                    ],
                }
                .into(),
            ),
            ..Default::default()
        };
        ctx.register_portal_with_layer(
            fission::core::PortalLayer::Modal,
            Some(WidgetId::explicit(&format!("{}-spotlight", host.id))),
            overlay.into(),
        );

        let card: Widget = OnboardingCard {
            id: format!("{}-card", host.id),
            step: step.clone(),
            labels: host.labels,
            style: host.style.clone(),
            first_step: host.current_step == 0,
            last_step: host.flow.is_last_step(host.current_step),
            busy: host.busy,
            continue_enabled: host.continue_enabled,
            contextual_action: host.contextual_action,
            contextual_status: host.contextual_status,
            on_back: host.on_back,
            on_continue: host.on_continue,
            on_skip: host.on_skip,
            on_complete: host.on_complete,
        }
        .into();
        let callout: Widget = Column {
            gap: Some(0.0),
            children: vec![
                Spacer {
                    height: Some(host.style.callout_gap),
                    ..Default::default()
                }
                .into(),
                card,
                Spacer {
                    height: Some(host.style.callout_gap),
                    ..Default::default()
                }
                .into(),
            ],
            ..Default::default()
        }
        .into();
        ctx.register_portal_with_layer(
            fission::core::PortalLayer::Flyout,
            Some(WidgetId::explicit(&format!("{}-callout", host.id))),
            fission::widgets::flyout(step.anchor, callout),
        );

        Spacer::default().into()
    }
}