fission-onboarding 0.1.0

Guided in-app tours and spotlight onboarding for Fission applications
Documentation
use fission::core::op::AlignItems;
use fission::icons::material;
use fission::prelude::*;

use crate::{OnboardingLabels, OnboardingStep, OnboardingStyle};

#[derive(Clone)]
pub(crate) struct OnboardingCard {
    pub id: String,
    pub step: OnboardingStep,
    pub labels: OnboardingLabels,
    pub style: OnboardingStyle,
    pub first_step: bool,
    pub last_step: bool,
    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 From<OnboardingCard> for Widget {
    fn from(card: OnboardingCard) -> Self {
        let (_, view) = fission::build::current::<()>();
        let back_label = resolve_text(view.env(), &card.labels.back);
        let continue_label = resolve_text(
            view.env(),
            if card.last_step {
                &card.labels.finish
            } else {
                &card.labels.continue_label
            },
        );
        let skip_label = resolve_text(view.env(), &card.labels.skip);

        let back: Widget = if card.first_step {
            Spacer::default().into()
        } else {
            Button {
                child: Some(
                    Row {
                        gap: Some(card.style.control_gap),
                        children: vec![
                            Icon::svg(material::navigation::arrow_back::regular())
                                .size(card.style.icon_size)
                                .color(card.style.text)
                                .into(),
                            Text::new(card.labels.back.clone())
                                .size(card.style.body_size)
                                .color(card.style.text)
                                .into(),
                        ],
                        ..Default::default()
                    }
                    .into(),
                ),
                on_press: (!card.busy).then(|| card.on_back.clone()).flatten(),
                semantics: Some(button_semantics(
                    format!("{}-back", card.id),
                    back_label,
                    card.busy,
                )),
                variant: ButtonVariant::SecondaryGray,
                size: ComponentSize::Sm,
                disabled: card.busy,
                ..Default::default()
            }
            .into()
        };
        let continue_action = if card.last_step {
            card.on_complete.clone()
        } else {
            card.on_continue.clone()
        };
        let continue_disabled = card.busy || !card.continue_enabled;
        let next: Widget = Button {
            child: Some(
                Row {
                    gap: Some(card.style.control_gap),
                    children: vec![
                        Text::new(if card.last_step {
                            card.labels.finish.clone()
                        } else {
                            card.labels.continue_label.clone()
                        })
                        .size(card.style.body_size)
                        .color(card.style.primary_text)
                        .into(),
                        Icon::svg(if card.last_step {
                            material::action::check_circle::outlined()
                        } else {
                            material::navigation::arrow_forward::regular()
                        })
                        .size(card.style.icon_size)
                        .color(card.style.primary_text)
                        .into(),
                    ],
                    ..Default::default()
                }
                .into(),
            ),
            on_press: (!continue_disabled).then_some(continue_action),
            semantics: Some(button_semantics(
                format!("{}-continue", card.id),
                continue_label.clone(),
                continue_disabled,
            )),
            variant: ButtonVariant::Primary,
            size: ComponentSize::Sm,
            disabled: continue_disabled,
            ..Default::default()
        }
        .into();
        let skip: Widget = if let Some(action) = card.on_skip {
            Button {
                child: Some(
                    Text::new(card.labels.skip.clone())
                        .size(card.style.body_size)
                        .color(card.style.text_muted)
                        .into(),
                ),
                on_press: (!card.busy).then_some(action),
                semantics: Some(button_semantics(
                    format!("{}-skip", card.id),
                    skip_label,
                    card.busy,
                )),
                variant: ButtonVariant::TertiaryGray,
                size: ComponentSize::Sm,
                disabled: card.busy,
                ..Default::default()
            }
            .into()
        } else {
            Spacer::default().into()
        };

        let busy_indicator = |suffix: &str| -> Widget {
            if card.busy {
                Spinner {
                    id: WidgetId::explicit(&format!("{}-{suffix}-saving", card.id)),
                    color: Some(card.style.focus_ring),
                    motion: Some(SpinnerMotion::Default),
                    label: Some(continue_label.clone()),
                }
                .into()
            } else {
                Spacer::default().into()
            }
        };
        let busy_wide = busy_indicator("wide");
        let busy_compact = busy_indicator("compact");
        let wide_actions: Widget = Row {
            gap: Some(card.style.control_gap),
            align_items: AlignItems::Center,
            children: vec![
                busy_wide,
                skip.clone(),
                Spacer {
                    flex_grow: 1.0,
                    ..Default::default()
                }
                .into(),
                back.clone(),
                next.clone(),
            ],
            ..Default::default()
        }
        .into();
        let compact_actions: Widget = Column {
            gap: Some(card.style.control_gap),
            children: vec![
                Row {
                    gap: Some(card.style.control_gap),
                    align_items: AlignItems::Center,
                    children: vec![
                        busy_compact,
                        skip,
                        Spacer {
                            flex_grow: 1.0,
                            ..Default::default()
                        }
                        .into(),
                    ],
                    ..Default::default()
                }
                .into(),
                Row {
                    gap: Some(card.style.control_gap),
                    align_items: AlignItems::Center,
                    children: vec![
                        Spacer {
                            flex_grow: 1.0,
                            ..Default::default()
                        }
                        .into(),
                        back,
                        next,
                    ],
                    ..Default::default()
                }
                .into(),
            ],
            ..Default::default()
        }
        .into();
        let actions = if view.viewport_size().width <= card.style.compact_actions_below {
            compact_actions
        } else {
            wide_actions
        };
        let mut content_children = vec![
            Text::new(card.labels.progress.clone())
                .size(card.style.progress_size)
                .color(card.style.text_muted)
                .semantics_identifier(format!("{}-progress", card.id))
                .into(),
            Text::new(card.step.title)
                .size(card.style.title_size)
                .weight(card.style.title_weight)
                .color(card.style.text)
                .into(),
            Text::new(card.step.body)
                .size(card.style.body_size)
                .color(card.style.text_muted)
                .into(),
        ];
        if let Some(status) = card.contextual_status {
            content_children.push(
                Text::new(status)
                    .size(card.style.body_size)
                    .weight(card.style.title_weight)
                    .color(card.style.text)
                    .semantics_identifier(format!("{}-status", card.id))
                    .into(),
            );
        }
        if let Some((label, action)) = card.contextual_action {
            let resolved_label = resolve_text(view.env(), &label);
            content_children.push(
                Button {
                    child: Some(
                        Text::new(label)
                            .size(card.style.body_size)
                            .color(card.style.primary_text)
                            .into(),
                    ),
                    on_press: (!card.busy).then_some(action),
                    semantics: Some(button_semantics(
                        format!("{}-context-action", card.id),
                        resolved_label,
                        card.busy,
                    )),
                    variant: ButtonVariant::Primary,
                    size: ComponentSize::Sm,
                    disabled: card.busy,
                    ..Default::default()
                }
                .into(),
            );
        }
        content_children.push(actions);
        let content = Column {
            gap: Some(card.style.card_gap),
            children: content_children,
            ..Default::default()
        };
        let mut surface = Container::new(content)
            .width_length(card.style.card_width)
            .bg(card.style.surface)
            .border(card.style.border, 1.0)
            .border_radius(card.style.card_radius)
            .padding_all(card.style.card_padding);
        if let Some(shadow) = card.style.shadow {
            surface = surface.shadow(shadow);
        }

        SemanticsRegion::new(surface)
            .identifier(card.id)
            .label(card.labels.dialog_label)
            .role(Role::Dialog)
            .into()
    }
}

fn button_semantics(identifier: String, label: String, disabled: bool) -> Semantics {
    Semantics {
        role: Role::Button,
        identifier: Some(identifier),
        label: Some(label),
        focusable: !disabled,
        disabled,
        ..Default::default()
    }
}

fn resolve_text(env: &Env, content: &TextContent) -> String {
    match content {
        TextContent::Literal(value) => value.clone(),
        TextContent::Key(key) => env
            .i18n
            .get(&env.locale, key)
            .unwrap_or(key.as_str())
            .to_string(),
        TextContent::KeyWithFallback { key, fallback } => env
            .i18n
            .get(&env.locale, key)
            .unwrap_or(fallback.as_str())
            .to_string(),
    }
}