fission-onboarding 0.1.0

Guided in-app tours and spotlight onboarding for Fission applications
Documentation
//! A task-gated tour: the user enters a name, creates an item, and sees it.
use fission::prelude::*;
use fission_onboarding::{OnboardingAnchor, OnboardingFlow, OnboardingHost, OnboardingStep};

#[derive(Clone, Debug, Default)]
struct State {
    name: String,
    created: bool,
    step: usize,
    done: bool,
}

impl GlobalState for State {}

#[fission_reducer(ChangeName)]
fn change_name(state: &mut State, ctx: &mut ReducerContext<State>) {
    if let Some(change) = ctx.input.text_change() {
        state.name = change.new_text.clone();
    }
}

#[fission_reducer(CreateItem)]
fn create_item(state: &mut State) {
    if !state.name.trim().is_empty() {
        state.created = true;
        state.step = 2;
    }
}

#[fission_reducer(NextStep)]
fn next_step(state: &mut State) {
    if state.step == 0 && !state.name.trim().is_empty() {
        state.step = 1;
    } else if state.step == 1 && state.created {
        state.step = 2;
    }
}

#[fission_reducer(BackStep)]
fn back_step(state: &mut State) {
    state.step = state.step.saturating_sub(1);
}

#[fission_reducer(FinishTour)]
fn finish_tour(state: &mut State) {
    state.done = true;
}

#[derive(Clone)]
struct App;

impl From<App> for Widget {
    fn from(_: App) -> Widget {
        let (ctx, view) = fission::build::current::<State>();
        let state = view.state();
        let field = WidgetId::explicit("demo.item-name");
        let create = WidgetId::explicit("demo.create-item");
        let result = WidgetId::explicit("demo.created-item");
        let flow = OnboardingFlow::new(
            "first-item",
            1,
            vec![
                OnboardingStep {
                    id: "name".into(),
                    anchor: field,
                    route: None,
                    title: "Name your item".into(),
                    body: "Type a name into the highlighted field.".into(),
                },
                OnboardingStep {
                    id: "create".into(),
                    anchor: create,
                    route: None,
                    title: "Create it".into(),
                    body: "Press the real Create button to finish this task.".into(),
                },
                OnboardingStep {
                    id: "result".into(),
                    anchor: result,
                    route: None,
                    title: "Here it is".into(),
                    body: "Your result appears in the app, not in a tour mockup.".into(),
                },
            ],
        )
        .expect("valid tour");

        let mut children = widgets![
            Text::new("Your items"),
            OnboardingAnchor {
                id: field,
                child: TextInput {
                    label: Some("Item name".into()),
                    value: state.name.clone(),
                    on_input: Some(ctx.bind(ChangeName, reduce_with!(change_name))),
                    ..Default::default()
                }
                .into(),
            },
            OnboardingAnchor {
                id: create,
                child: Button {
                    child: Some(Text::new("Create item").into()),
                    on_press: Some(ctx.bind(CreateItem, reduce_with!(create_item))),
                    disabled: state.name.trim().is_empty(),
                    ..Default::default()
                }
                .into(),
            },
            OnboardingAnchor {
                id: result,
                child: Text::new(if state.created {
                    format!("Created: {}", state.name)
                } else {
                    "No item yet".into()
                })
                .into(),
            },
        ];
        if !state.done {
            let finish = ctx.bind(FinishTour, reduce_with!(finish_tour));
            let mut tour = OnboardingHost::new(
                "first-item",
                flow,
                state.step,
                ctx.bind(NextStep, reduce_with!(next_step)),
                finish.clone(),
            );
            tour.labels.progress = format!("Step {} of 3", state.step + 1).into();
            tour.continue_enabled = match state.step {
                0 => !state.name.trim().is_empty(),
                1 => state.created,
                _ => true,
            };
            tour.on_back = (state.step > 0).then(|| ctx.bind(BackStep, reduce_with!(back_step)));
            tour.on_skip = Some(finish);
            children.push(tour.into());
        }
        Container::new(Column {
            gap: Some(20.0),
            children,
            ..Default::default()
        })
        .padding_all(32.0)
        .into()
    }
}

fn main() {
    DesktopApp::<State, _>::new(App).run().expect("start app");
}