# Order entry — a wizard

> A complete example app — several screens showing how the pieces fit together.

A linear flow that validates per step, lets the reader walk back freely, and ends on a review built from the same data the steps collected.

- Framework: CrossFly (Java; one source for Android, iOS, desktop (Windows, macOS and Linux) and the web — Chrome, Firefox, Safari and other browsers)
- Entry point: `dev.cf.app.wizard.WizardApp`
- Topics: forms, navigation
- Run it locally: `gradlew lwjgl3:run --args="classname dev.cf.app.wizard.WizardApp"`
- Run it in a browser: https://crossfly.dev/playground/index.html?ex=app-wizard
- Page: https://crossfly.dev/examples/app-wizard

## What this shows

**Order entry** — the wizard shape: a linear flow with per-step validation and a review.

```
gradlew lwjgl3:run --args="classname dev.cf.app.wizard.WizardApp"
```

## The shape

One host Window that owns the trail, the footer and the current step; the steps themselves are
plain objects (`WizardStep`) that draw into a body the host provides. Not screens, not
windows — because the host has to be able to rebuild any of them at any time, in either
direction, and a stack of Windows cannot do that.

### The four rules that make a wizard bearable

  - All state lives in one object (Order), never in the steps. This is what makes
      going backwards work at all — see the Order javadoc for what happens otherwise.

  - Backwards is always free. back() never validates. Blocking a user from
      returning to fix something because the step they are on is incomplete is the single most
      infuriating thing a wizard can do, and it is usually an accident of putting one validate
      call in the wrong place.

  - Forwards validates, and says what and where. problem() returns a
      sentence, which the footer shows. "Please complete all fields" is what a wizard says when
      nobody decided who owns the rule.

  - The trail is navigation, not decoration. Tapping a dot goes there — subject to the
      same forward rule, so you can always jump back and only jump forward through completed
      steps. StepTrail draws it; the gating is the app's business, which is why the
      widget does not attempt it.

What is deliberately missing: saving a half-finished order and resuming it later. It is a real
requirement in real order entry, it is a persistence problem rather than a wizard problem, and
the seam for it is exactly the `Order` object — serialise that one thing and you have it.

## Framework types it uses

`Alert`, `Button`, `Card`, `Component`, `Debug`, `Dropdown`, `Gap`, `GridLayout`, `Group`, `Hint`, `Label`, `MaterialIcon`, `Option`, `Screen`, `StepTrail`, `Styles`, `Switch`, `TextField`, `VerticalLayout`, `Widgets`, `Window`

## Source

### dev/cf/app/wizard/WizardApp.java

```java
package dev.cf.app.wizard;

import dev.cf.Described;
import dev.cf.Tones;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;

import dev.crossfly.ui.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import java.util.ArrayList;
import java.util.List;

import static dev.crossfly.ui.Widgets.*;

/**
 * <b>Order entry</b> — the wizard shape: a linear flow with per-step validation and a review.
 *
 * <pre>
 * gradlew lwjgl3:run --args="classname dev.cf.app.wizard.WizardApp"
 * </pre>
 *
 * <h2>The shape</h2>
 *
 * <p>One host Window that owns the trail, the footer and the current step; the steps themselves are
 * plain objects ({@link WizardStep}) that draw into a body the host provides. Not screens, not
 * windows — because the host has to be able to rebuild any of them at any time, in either
 * direction, and a stack of Windows cannot do that.</p>
 *
 * <h3>The four rules that make a wizard bearable</h3>
 *
 * <ol>
 *   <li><b>All state lives in one object</b> ({@link Order}), never in the steps. This is what makes
 *       going backwards work at all — see the {@code Order} javadoc for what happens otherwise.</li>
 *   <li><b>Backwards is always free.</b> {@link #back()} never validates. Blocking a user from
 *       returning to fix something because the step they are on is incomplete is the single most
 *       infuriating thing a wizard can do, and it is usually an accident of putting one validate
 *       call in the wrong place.</li>
 *   <li><b>Forwards validates, and says what and where.</b> {@link WizardStep#problem()} returns a
 *       sentence, which the footer shows. "Please complete all fields" is what a wizard says when
 *       nobody decided who owns the rule.</li>
 *   <li><b>The trail is navigation, not decoration.</b> Tapping a dot goes there — subject to the
 *       same forward rule, so you can always jump back and only jump forward through completed
 *       steps. {@link StepTrail} draws it; the gating is the app's business, which is why the
 *       widget does not attempt it.</li>
 * </ol>
 *
 * <p>What is deliberately missing: saving a half-finished order and resuming it later. It is a real
 * requirement in real order entry, it is a persistence problem rather than a wizard problem, and
 * the seam for it is exactly the {@link Order} object — serialise that one thing and you have it.</p>
 */
public class WizardApp extends Window implements Described
{
   private final Order order = new Order();
   private final List<WizardStep> steps = new ArrayList<>();

   private int current;

   private StepTrail trail;
   private Component body;
   private Label problem;
   private Button back, next;

   @Override
   public String title()
   {
      return "Order entry — a wizard";
   }

   @Override
   public String note()
   {
      return "A linear flow that validates per step, lets the reader walk back freely, and ends"
         + " on a review built from the same data the steps collected.";
   }

   /** Hands the palette back to whatever was in force before this app opened. */
   @Override
   public void onRemove()
   {
      super.onRemove();
      AppTheme.leave();
   }

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      if (!firstTime)
         return;
      // The brand FIRST: fullscreenColor below reads a token, and reading it before the palette
      // is declared bakes in the skin's neutral grey for the life of the window.
      AppTheme.enter();
      // The PAGE, not the surface. Cards, buttons and fields are all drawn on the surface, so a
      // page painted the same colour makes every one of their edges disappear. Read through
      // onRestyle so a light/dark flip re-derives it instead of keeping the old mode's value.
      fullscreenColor = AppTheme.pageColor();
      onRestyle(() -> fullscreenColor = AppTheme.pageColor());
      setLayout(new VerticalLayout(Position.START, Position.START));

      steps.add(new CustomerStep(order));
      steps.add(new ItemsStep(order));
      steps.add(new DeliveryStep(order));
      // The review is built over the OTHER steps, so it is added holding a list that excludes it.
      steps.add(new ReviewStep(order, new ArrayList<>(steps), this));

      add(0f, new AppBar("New order", isRoot() ? null : this::hide));

      trail = new StepTrail();
      for (int i = 0; i < steps.size(); i++)
      {
         final int idx = i;
         WizardStep s = steps.get(i);
         trail.step(s.title(), s.icon(), () -> goToStep(idx));
      }
      trail.testTag("wizard.trail");
      trail.getStyle()
         .ingap(Gap.ofFont(1f, .6f, 1f, .6f));
      add(0f, trail);

      body = new Component();
      body.setScrollable(ScrollDirection.VERTICAL);
      body.getStyle()
         .ingap(Gap.ofFont(1f, 1f, 1f, 1f));
      body.testTag("wizard.body");
      add(LAY_FILL, body);

      add(0f, footer());
      showStep(0);
   }

   private Component footer() throws Exception
   {
      Component f = new Component();
      f.setLayout(new VerticalLayout(Position.START, Position.START));
      f.getStyle()
         .backColor(Styles.backColor)
         .shadow(0, -.06f, .25f, Styles.borderColor)
         .ingap(Gap.ofFont(1f, .6f, 1f, .6f));

      // The message sits ABOVE the buttons, next to the thing it is about — not in a dialog. A
      // validation error in a modal makes the user dismiss it before they can act on it.
      problem = label("", 1f, false, secondary(), Position.START);
      problem.testTag("wizard.problem");
      problem.setVisible(false);
      f.add(LAY_AUTO, outgap(problem, 0, 0, 0, .5f));

      Component row = new Component();
      row.setLayout(new GridLayout(Position.START, Position.CENTER));

      back = new Button("Back");
      back.testTag("wizard.back");
      back.onAction(c -> back());

      next = Tones.action("Next");
      next.testTag("wizard.next");
      next.onAction(c -> next());

      row.add(.5f, .5f, back, next);
      f.add(LAY_AUTO, row);
      return f;
   }

   /**
    * Jumps to a step.
    *
    * <p>Backwards is unconditional. Forwards runs the same gate {@link #next()} does, one step at a
    * time, so the trail cannot be used to skip a validation that the Next button enforces.</p>
    *
    * @param index the step to go to
    */
   public void goToStep(int index)
   {
      if (index == current)
         return;
      if (index < current)
      {
         steps.get(current)
            .save();
         showStep(index);
         return;
      }
      while (current < index)
         if (!next())
            return;
   }

   /**
    * Advances one step if the current one is complete.
    *
    * @return whether it moved
    */
   private boolean next()
   {
      WizardStep s = steps.get(current);
      s.save();
      String p = s.problem();
      if (p != null)
      {
         say(p);
         return false;
      }
      if (current == steps.size() - 1)
      {
         confirm();
         return false;
      }
      showStep(current + 1);
      return true;
   }

   /** Goes back one step. Saves on the way out, and never validates. */
   private void back()
   {
      if (current == 0)
         return;
      steps.get(current)
         .save();
      showStep(current - 1);
   }

   private void showStep(int index)
   {
      try
      {
         current = index;
         say(null);

         body.removeAll();
         // removeAll() takes the layout with it.
         body.setLayout(new VerticalLayout(Position.START, Position.START));
         steps.get(index)
            .build(body);

         trail.current(index);
         trail.header((index + 1) + "/" + steps.size(), steps.get(index)
            .title()
            .toUpperCase(), "Total", Order.money(order.totalCents()));

         back.setEnabled(index > 0);
         next.setText(index == steps.size() - 1 ? "Place order" : "Next");

         setValidLayout(false);
         Screen.repaint();
      }
      catch (Exception e)
      {
         dev.crossfly.util.Debug.handle(e);
      }
   }

   private void say(String message)
   {
      problem.setText(message == null ? "" : message);
      problem.setVisible(message != null);
      problem.setValidLayout(false);
      if (message != null)
         Screen.repaint();
   }

   /**
    * The end of the flow.
    *
    * <p>It asks once, naming the total, and it is the only confirmation in the whole wizard. A
    * wizard that confirms each step teaches the user to dismiss confirmations, which is exactly the
    * habit you do not want them to have when they reach this one.</p>
    */
   private void confirm()
   {
      Alert a = new Alert("Place this order?",
         "Total " + Order.money(order.totalCents()) + " for " + order.customer + ".", "Cancel",
         "Place order");
      a.buttonAction(1, c ->
      {
         Hint.show("Order placed", 2500);
         hide();
      });
      a.show();
   }
}
```

### dev/cf/app/wizard/WizardStep.java

```java
package dev.cf.app.wizard;

import dev.crossfly.ui.Component;

/**
 * One step of the wizard.
 *
 * <p>The contract is four methods, and each of them exists because of a mistake that is easy to
 * make without it.</p>
 *
 * <ul>
 *   <li>{@link #build(Component)} — draw the step. Called every time the step is entered, forwards
 *       or backwards, so it must read its values from the {@link Order} rather than assume it is
 *       being built for the first time.</li>
 *   <li>{@link #save()} — copy what is on screen into the order. Called when the user leaves the
 *       step in <b>either</b> direction. Saving only on Next is how a wizard loses an edit the user
 *       made just before pressing Back.</li>
 *   <li>{@link #problem()} — say what is wrong, in words, or null when the step is complete.
 *       Returning a boolean instead is what produces "Please complete this step".</li>
 *   <li>{@link #summary()} — the lines the review page shows for this step. It lives here because
 *       the step is the only thing that knows which of its fields matter; a review page that
 *       reaches into the order and formats everything itself has to be edited every time any step
 *       changes.</li>
 * </ul>
 */
public abstract class WizardStep
{
   /** The order every step reads from and writes to. */
   protected final Order order;

   /**
    * @param order the shared order
    */
   protected WizardStep(Order order)
   {
      this.order = order;
   }

   /** The step's name, shown on the trail and as the heading. */
   public abstract String title();

   /** The Material Icons codepoint for this step's dot on the trail. */
   public abstract int icon();

   /** A stable prefix for this step's test tags. */
   public abstract String tag();

   /**
    * Draws the step.
    *
    * @param body a vertical, scrollable container to add to
    * @throws Exception if building fails
    */
   public abstract void build(Component body) throws Exception;

   /** Copies the on-screen values into the order. Called on the way out, in both directions. */
   public abstract void save();

   /**
    * What is stopping this step from being complete.
    *
    * @return the message to show, or null when the step is in order
    */
   public abstract String problem();

   /**
    * The label/value pairs the review page shows for this step.
    *
    * @return an even-length array, {@code label, value, label, value…}
    */
   public abstract String[] summary();
}
```

### dev/cf/app/wizard/Order.java

```java
package dev.cf.app.wizard;

import java.util.ArrayList;
import java.util.List;

/**
 * What the wizard is collecting: one object, filled in over four steps.
 *
 * <p><b>The single most important decision in a wizard shape.</b> The steps write into this and
 * read from it; no step holds state of its own that survives its own screen. That is what makes
 * going backwards work — a step rebuilt on the way back reads what it wrote and shows it — and it
 * is what makes the review page possible at all, because the review has one place to read from
 * rather than four screens to interrogate.</p>
 *
 * <p>The alternative, where each step keeps its own fields and hands them forward, works right up
 * until the user goes back two steps and changes something. Then the forward hand-off has already
 * happened, and half the wizard is holding stale copies.</p>
 */
public class Order
{
   /** One line of the order. */
   public static class Line
   {
      /** What was ordered. */
      public final String product;
      /** How many. */
      public int quantity;
      /** Unit price in cents. */
      public final long unitCents;

      /**
       * @param product   the product name
       * @param quantity  how many
       * @param unitCents the unit price in cents
       */
      public Line(String product, int quantity, long unitCents)
      {
         this.product = product;
         this.quantity = quantity;
         this.unitCents = unitCents;
      }

      /** This line's total, in cents. */
      public long totalCents()
      {
         return quantity * unitCents;
      }
   }

   // ── Step 1: who ──
   /** The customer's name. */
   public String customer = "";
   /** Contact e-mail. */
   public String email = "";
   /** Purchase-order reference the customer gave, if any. */
   public String reference = "";

   // ── Step 2: what ──
   /** The order lines. */
   public final List<Line> lines = new ArrayList<>();

   // ── Step 3: how ──
   /** Index into {@link #SHIPPING}. */
   public int shipping = -1;
   /** Delivery address. */
   public String address = "";
   /** Whether the customer asked for a delivery note. */
   public boolean deliveryNote = true;

   /** The shipping options, and their cost in cents. */
   public static final String[] SHIPPING = { "Standard — 5 working days", "Express — next day",
      "Collection — customer picks up" };

   /** Cost of each {@link #SHIPPING} option, in cents. */
   public static final long[] SHIPPING_COST = { 0, 2450, 0 };

   /** The catalogue the items step offers. */
   public static final String[] PRODUCT = { "Ball bearing 6204", "Oil seal 35x52", "Relay 24V",
      "Hydraulic hose 1/2", "Filter cartridge 10µ" };

   /** Unit price of each {@link #PRODUCT}, in cents. */
   public static final long[] PRICE = { 1240, 890, 3150, 2270, 1680 };

   /** The sum of the lines, before shipping. */
   public long subtotalCents()
   {
      long t = 0;
      for (Line l : lines)
         t += l.totalCents();
      return t;
   }

   /** What shipping costs, or 0 when nothing has been chosen yet. */
   public long shippingCents()
   {
      return shipping < 0 ? 0 : SHIPPING_COST[shipping];
   }

   /** The figure the review page shows and the confirm button commits to. */
   public long totalCents()
   {
      return subtotalCents() + shippingCents();
   }

   /**
    * Formats cents as money.
    *
    * @param cents the amount
    * @return the formatted amount
    */
   public static String money(long cents)
   {
      return cents / 100 + "." + (cents % 100 < 10 ? "0" : "") + cents % 100;
   }
}
```

### dev/cf/app/wizard/CustomerStep.java

```java
package dev.cf.app.wizard;

import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/** Step 1 — who the order is for. Three fields, one of them optional, and that is the whole step. */
public class CustomerStep extends WizardStep
{
   private TextField customer, email, reference;

   /**
    * @param order the shared order
    */
   public CustomerStep(Order order)
   {
      super(order);
   }

   public String title()
   {
      return "Customer";
   }

   public int icon()
   {
      return 0xE7FD;   // person
   }

   public String tag()
   {
      return "step.customer";
   }

   public void build(Component body) throws Exception
   {
      body.add(LAY_AUTO, outgap(label("Who is this order for?", 1.5f, true, Styles.textColor,
         Position.START), 0, 0, 0, 1f));

      customer = field(body, "Customer", order.customer, "step.customer.name");
      email = field(body, "E-mail", order.email, "step.customer.email");
      reference = field(body, "Their reference (optional)", order.reference,
         "step.customer.reference");

      // Saying which field is optional is worth one label. Without it every field looks required,
      // and a user who has no PO number stops here and goes to find one.
      body.add(LAY_AUTO, label("We will send the confirmation to this address.", 1f, false,
         Styles.captionColor, Position.START));
   }

   private TextField field(Component body, String caption, String value, String tag)
      throws Exception
   {
      TextField f = new TextField(value == null ? "" : value);
      f.caption(caption);
      f.testTag(tag);
      body.add(LAY_AUTO, outgap(f, 0, 0, 0, .8f));
      return f;
   }

   public void save()
   {
      order.customer = customer.getText()
         .trim();
      order.email = email.getText()
         .trim();
      order.reference = reference.getText()
         .trim();
   }

   public String problem()
   {
      if (order.customer.isEmpty())
         return "We need the customer's name.";
      // Deliberately not a regular expression. The only thing a client can usefully check is that
      // the address is shaped like one; deciding whether it exists is the mail server's job, and an
      // over-strict pattern rejects addresses that are perfectly valid.
      if (order.email.indexOf('@') < 1 || order.email.indexOf('.', order.email.indexOf('@')) < 0)
         return "That e-mail address does not look complete.";
      return null;
   }

   public String[] summary()
   {
      return new String[] { "Customer", order.customer, "E-mail", order.email, "Reference",
         order.reference.isEmpty() ? "—" : order.reference };
   }
}
```

### dev/cf/app/wizard/ItemsStep.java

```java
package dev.cf.app.wizard;

import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * Step 2 — what is being ordered.
 *
 * <p>The step with a list inside it, which is the one that makes a wizard interesting. Two things
 * follow from that and neither is obvious until it bites:</p>
 *
 * <ul>
 *   <li><b>It writes to the order as it goes</b>, not in {@code save()}. Add and remove are already
 *       edits to the order; keeping a shadow list and reconciling it later means two lists that can
 *       disagree, and the running total is computed from one of them.</li>
 *   <li><b>The running total is on screen the whole time.</b> A total the user only meets on the
 *       review page is a total that sends them back through the wizard.</li>
 * </ul>
 */
public class ItemsStep extends WizardStep
{
   private Component list;
   private Label total;
   private Dropdown product;
   private TextField quantity;

   /**
    * @param order the shared order
    */
   public ItemsStep(Order order)
   {
      super(order);
   }

   public String title()
   {
      return "Items";
   }

   public int icon()
   {
      return 0xE8CC;   // shopping cart
   }

   public String tag()
   {
      return "step.items";
   }

   public void build(Component body) throws Exception
   {
      body.add(LAY_AUTO, outgap(label("What are they ordering?", 1.5f, true, Styles.textColor,
         Position.START), 0, 0, 0, 1f));

      Component add = new Component();
      add.setLayout(new GridLayout(Position.START, Position.CENTER));

      product = new Dropdown("Product", Order.PRODUCT);
      product.testTag("step.items.product");

      quantity = new TextField("1");
      quantity.caption("Qty");
      quantity.keyboard(Keyboard.NUMERIC);
      quantity.testTag("step.items.qty");

      Button plus = new Button(MaterialIcon.of(0xE145, 1.4f));   // add
      plus.testTag("step.items.add");
      plus.onAction(c -> addLine());

      add.add(1f, 0f, 0f, product, outgap(quantity, .5f, 0, .5f, 0), plus);
      body.add(LAY_AUTO, outgap(add, 0, 0, 0, 1f));

      list = new Component();
      list.setLayout(new VerticalLayout(Position.START, Position.START));
      list.testTag("step.items.list");
      body.add(LAY_AUTO, list);

      total = label("", 1.5f, true, Styles.textColor, Position.END);
      total.testTag("step.items.total");
      body.add(LAY_AUTO, outgap(total, 0, .8f, 0, 0));

      rebuildList();
   }

   private void addLine()
   {
      int p = product.getSelectedIndex();
      if (p < 0)
         return;
      int q;
      try
      {
         q = Integer.parseInt(quantity.getText()
            .trim());
      }
      catch (NumberFormatException e)
      {
         q = 0;
      }
      if (q <= 0)
         return;

      // Same product twice is one line with a bigger quantity, not two lines. The alternative
      // renders as a list that looks like a mistake and totals correctly, which is worse than
      // either being right or being obviously wrong.
      for (Order.Line l : order.lines)
         if (l.product.equals(Order.PRODUCT[p]))
         {
            l.quantity += q;
            rebuildList();
            Screen.repaint();
            return;
         }
      order.lines.add(new Order.Line(Order.PRODUCT[p], q, Order.PRICE[p]));
      rebuildList();
      Screen.repaint();
   }

   private void rebuildList()
   {
      try
      {
         list.removeAll();
         // removeAll() removes the layout with the children — put it back first.
         list.setLayout(new VerticalLayout(Position.START, Position.START));

         if (order.lines.isEmpty())
            list.add(LAY_AUTO, label("Nothing on this order yet.", 1f, false, Styles.captionColor,
               Position.START));

         for (int i = 0; i < order.lines.size(); i++)
         {
            final Order.Line l = order.lines.get(i);
            Card row = new Card();
            row.setLayout(new GridLayout(Position.START, Position.CENTER));
            row.getStyle()
               .outgap(Gap.ofFont(0, 0, 0, .4f));

            Component text = new Component();
            text.setLayout(new VerticalLayout(Position.START, Position.START));
            text.add(LAY_AUTO, label(l.product, 1.2f, true, Styles.textColor, Position.START));
            text.add(LAY_AUTO, label(l.quantity + " × " + Order.money(l.unitCents), 1f, false,
               Styles.captionColor, Position.START));

            Button remove = new Button(MaterialIcon.of(0xE872, 1.2f));   // delete
            remove.testTag("step.items.remove." + i);
            remove.onAction(c ->
            {
               order.lines.remove(l);
               rebuildList();
               Screen.repaint();
            });

            row.add(1f, 0f, 0f, text, label(Order.money(l.totalCents()), 1.2f, true,
               Styles.textColor, Position.END), outgap(remove, .6f, 0, 0, 0));
            list.add(LAY_AUTO, row);
         }

         total.setText("Subtotal  " + Order.money(order.subtotalCents()));
         total.setValidLayout(false);
         list.setValidLayout(false);
      }
      catch (Exception e)
      {
         dev.crossfly.util.Debug.handle(e);
      }
   }

   public void save()
   {
      // Nothing to do: add and remove already wrote to the order. Leaving this empty is the point —
      // there is no second copy that could be out of step.
   }

   public String problem()
   {
      return order.lines.isEmpty() ? "Add at least one item." : null;
   }

   public String[] summary()
   {
      String[] out = new String[order.lines.size() * 2 + 2];
      int k = 0;
      for (Order.Line l : order.lines)
      {
         out[k++] = l.quantity + " × " + l.product;
         out[k++] = Order.money(l.totalCents());
      }
      out[k++] = "Subtotal";
      out[k] = Order.money(order.subtotalCents());
      return out;
   }
}
```

### dev/cf/app/wizard/DeliveryStep.java

```java
package dev.cf.app.wizard;

import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * Step 3 — how it gets there.
 *
 * <p>The step where one choice changes what the rest of the step asks: collection needs no address.
 * Hiding the address field rather than disabling it is the right call here — a disabled field
 * still reads as something you failed to fill in — and it is why {@link #problem()} checks the
 * address only when it is actually being asked for.</p>
 *
 * <p>The options are {@link Option}s in a group rather than a dropdown, because there are three of
 * them, they carry a price, and the user should be able to compare them without opening anything.
 * A dropdown is right when the list is long or the choice is uninteresting; here it is neither.</p>
 */
public class DeliveryStep extends WizardStep
{
   private Option[] options;
   private TextField address;
   private Component addressBox;
   private Switch note;

   /**
    * @param order the shared order
    */
   public DeliveryStep(Order order)
   {
      super(order);
   }

   public String title()
   {
      return "Delivery";
   }

   public int icon()
   {
      return 0xE558;   // local shipping
   }

   public String tag()
   {
      return "step.delivery";
   }

   public void build(Component body) throws Exception
   {
      body.add(LAY_AUTO, outgap(label("How should it get there?", 1.5f, true, Styles.textColor,
         Position.START), 0, 0, 0, 1f));

      Group group = new Group();
      options = new Option[Order.SHIPPING.length];
      for (int i = 0; i < options.length; i++)
      {
         final int idx = i;
         String cost = Order.SHIPPING_COST[i] == 0 ? "included"
            : Order.money(Order.SHIPPING_COST[i]);
         Option o = new Option(Order.SHIPPING[i] + "  ·  " + cost);
         o.testTag("step.delivery.option." + i);
         o.setChecked(order.shipping == i);
         o.onAction(c ->
         {
            order.shipping = idx;
            showAddress(idx != 2);
            Screen.repaint();
         });
         group.add(o);
         options[i] = o;
         body.add(LAY_AUTO, outgap(o, 0, 0, 0, .4f));
      }

      addressBox = new Component();
      addressBox.setLayout(new VerticalLayout(Position.START, Position.START));
      address = new TextField(order.address == null ? "" : order.address);
      address.caption("Delivery address");
      address.testTag("step.delivery.address");
      addressBox.add(LAY_AUTO, outgap(address, 0, .8f, 0, .8f));
      body.add(LAY_AUTO, addressBox);

      note = new Switch("Include a delivery note");
      note.checked(order.deliveryNote);
      note.testTag("step.delivery.note");
      body.add(LAY_AUTO, note);

      showAddress(order.shipping != 2);
   }

   private void showAddress(boolean show)
   {
      addressBox.setVisible(show);
      addressBox.setValidLayout(false);
   }

   public void save()
   {
      order.address = address.getText()
         .trim();
      order.deliveryNote = note.isChecked();
      for (int i = 0; i < options.length; i++)
         if (options[i].isChecked())
            order.shipping = i;
   }

   public String problem()
   {
      if (order.shipping < 0)
         return "Choose how it should be delivered.";
      if (order.shipping != 2 && order.address.isEmpty())
         return "We need an address to deliver to.";
      return null;
   }

   public String[] summary()
   {
      return new String[] { "Shipping", order.shipping < 0 ? "—" : Order.SHIPPING[order.shipping],
         "Address", order.shipping == 2 ? "Collection" : order.address,
         "Delivery note", order.deliveryNote ? "Yes" : "No" };
   }
}
```

### dev/cf/app/wizard/ReviewStep.java

```java
package dev.cf.app.wizard;

import java.util.List;

import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * Step 4 — the review, which is the step a wizard exists for.
 *
 * <p>Every step before this one asked for something. This one is the only place the user sees the
 * whole thing at once, and it is where they either commit or notice the mistake. Three properties
 * make it work, and all three are about not duplicating the steps:</p>
 *
 * <ul>
 *   <li><b>It is built from {@link WizardStep#summary()}</b>, not from the order directly. A review
 *       that formats the order itself is a second implementation of every step's presentation, and
 *       it goes stale the first time a step changes what it collects.</li>
 *   <li><b>Every block links back to the step that filled it.</b> The user who spots a wrong e-mail
 *       here wants to fix the e-mail, not to press Back three times counting screens.</li>
 *   <li><b>The total is stated once, at the bottom, in full.</b> This is the number being agreed to;
 *       burying it in a row of grey labels is how disputes start.</li>
 * </ul>
 */
public class ReviewStep extends WizardStep
{
   private final List<WizardStep> steps;
   private final WizardApp app;

   /**
    * @param order the shared order
    * @param steps the steps to summarise — this one is not among them
    * @param app   the host, so an "Edit" link can jump back to a step
    */
   public ReviewStep(Order order, List<WizardStep> steps, WizardApp app)
   {
      super(order);
      this.steps = steps;
      this.app = app;
   }

   public String title()
   {
      return "Review";
   }

   public int icon()
   {
      return 0xE8F4;   // fact check
   }

   public String tag()
   {
      return "step.review";
   }

   public void build(Component body) throws Exception
   {
      body.add(LAY_AUTO, outgap(label("Check this over", 1.5f, true, Styles.textColor,
         Position.START), 0, 0, 0, .2f));
      body.add(LAY_AUTO, outgap(label("Nothing is sent until you confirm.", 1f, false,
         Styles.captionColor, Position.START), 0, 0, 0, 1f));

      for (int i = 0; i < steps.size(); i++)
         body.add(LAY_AUTO, outgap(block(steps.get(i), i), 0, 0, 0, .6f));

      Card totals = new Card();
      totals.setLayout(new VerticalLayout(Position.START, Position.START));
      totals.testTag("step.review.totals");
      totals.add(LAY_AUTO, line("Subtotal", Order.money(order.subtotalCents()), false));
      totals.add(LAY_AUTO, line("Shipping", order.shippingCents() == 0 ? "included"
         : Order.money(order.shippingCents()), false));
      totals.add(LAY_AUTO, line("Total", Order.money(order.totalCents()), true));
      body.add(LAY_AUTO, outgap(totals, 0, .6f, 0, 0));
   }

   private Component block(final WizardStep s, final int index) throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));
      c.testTag("step.review.block." + index);

      Component head = new Component();
      head.setLayout(new GridLayout(Position.START, Position.CENTER));
      Button edit = new Button("Edit");
      edit.testTag("step.review.edit." + index);
      edit.onAction(e -> app.goToStep(index));
      head.add(1f, 0f, label(s.title(), 1.2f, true, Styles.textColor, Position.START), edit);
      c.add(LAY_AUTO, outgap(head, 0, 0, 0, .5f));

      String[] pairs = s.summary();
      for (int i = 0; i + 1 < pairs.length; i += 2)
         c.add(LAY_AUTO, line(pairs[i], pairs[i + 1], false));
      return c;
   }

   private Component line(String name, String value, boolean strong) throws Exception
   {
      Component c = new Component();
      c.setLayout(new GridLayout(Position.START, Position.CENTER));
      c.getStyle()
         .outgap(Gap.ofFont(0, 0, 0, .3f));
      c.add(.5f, .5f, label(name, strong ? 1.2f : 1f, strong, strong ? Styles.textColor
         : Styles.captionColor, Position.START), label(value == null || value.isEmpty() ? "—"
            : value, strong ? 1.5f : 1f, true, Styles.textColor, Position.END));
      return c;
   }

   public void save()
   {
      // Nothing to save: this step collects nothing. It reads.
   }

   public String problem()
   {
      // The steps before this one already answered for themselves; asking again here would only
      // duplicate their rules and let the two drift apart.
      return null;
   }

   public String[] summary()
   {
      return new String[0];
   }
}
```

## Shared scaffolding

Not part of this example, but read by it: the app-level classes the four example apps share.

### dev/cf/app/AppBar.java

```java
package dev.cf.app;

import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * The top bar the four example apps share: back, title, and up to a couple of actions.
 *
 * <p>There is no top bar in CrossFly, and that is not an omission. A title bar is the single most
 * app-specific piece of chrome there is — height, colour, whether it collapses, whether the title
 * centres, what sits on the right — and a framework that decided those would be fought by every
 * app that used it. What the framework supplies is what a bar is <i>made of</i>: a container, a
 * layout, buttons, glyphs, and a style that follows the skin.</p>
 *
 * <p>So this is an <b>app-level</b> class, shared by the four examples here because they are four
 * examples in one repository — in a real project it would live in your own {@code ui} package. It
 * is about forty lines, which is roughly what a top bar costs, and it is worth reading as the
 * answer to "how do I compose chrome out of primitives".</p>
 *
 * <pre>
 * add(0f, new AppBar("Part", this::hide).action(0xE3C9, this::edit));
 * </pre>
 */
public class AppBar extends Component
{
   private final String title;
   private final Runnable onBack;

   private final int[] icons = new int[3];
   private final Runnable[] actions = new Runnable[3];
   private final String[] tags = new String[3];
   private int count;

   /**
    * @param title  the heading
    * @param onBack what the back arrow does, or null for a bar with no back arrow
    */
   public AppBar(String title, Runnable onBack)
   {
      this.title = title;
      this.onBack = onBack;
   }

   /**
    * Adds a right-hand action. Up to three; past that the answer is a menu, not a fourth glyph.
    *
    * @param codepoint the Material Icons codepoint
    * @param tag       the test tag for the button
    * @param action    what it does
    * @return this bar, for chaining
    */
   public AppBar action(int codepoint, String tag, Runnable action)
   {
      if (count < icons.length)
      {
         icons[count] = codepoint;
         tags[count] = tag;
         actions[count] = action;
         count++;
      }
      return this;
   }

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      if (!firstTime)
         return;
      // A strip of surface with a hairline of elevation under it. Both colours are theme tokens, so
      // the bar is right in dark mode and under every skin without a branch anywhere — and the
      // shadow is expressed as a ratio of the font height, so it survives a high-DPI screen, where
      // a shadow measured in pixels all but disappears.
      getStyle().backColor(Styles.backColor)
         .shadow(0, .06f, .25f, Styles.borderColor)
         .ingap(Gap.ofFont(.5f, .4f, .5f, .4f));

      setLayout(new GridLayout(Position.START, Position.CENTER));

      // A bar with no back arrow gets no COLUMN for one. The obvious shortcut — always build the
      // column and fill it with a bare `new Component()` — silently kills the entire row: an empty
      // container has no layout, an auto-width column cannot measure it, and the bar comes out
      // zero-high with the title inside it. It looks like the title was never added.
      boolean hasBack = onBack != null;
      int columns = (hasBack ? 1 : 0) + 1 + count;
      Object[] cells = new Object[columns * 2];

      int k = 0;
      if (hasBack)
         cells[k++] = 0f;                  // back: its own width
      cells[k++] = 1f;                     // title: everything left over
      for (int i = 0; i < count; i++)
         cells[k++] = 0f;                  // actions: their own width

      if (hasBack)
      {
         Button b = new Button(MaterialIcon.of(0xE5C4, 1.4f));   // arrow_back
         b.testTag("bar.back");
         b.onAction(c -> onBack.run());
         b.getStyle()
            .outgap(Gap.ofFont(0, 0, .5f, 0));
         cells[k++] = b;
      }

      Label t = label(title, 1.5f, true, Styles.textColor, Position.START);
      t.testTag("bar.title");
      cells[k++] = t;

      for (int i = 0; i < count; i++)
      {
         Button b = new Button(MaterialIcon.of(icons[i], 1.4f));
         final Runnable a = actions[i];
         b.testTag(tags[i]);
         b.onAction(c -> a.run());
         b.getStyle()
            .outgap(Gap.ofFont(.4f, 0, 0, 0));
         cells[k++] = b;
      }

      add(cells);
   }
}
```

### dev/cf/app/AppTheme.java

```java
package dev.cf.app;

import dev.crossfly.ui.color.ColorScheme;
import dev.crossfly.ui.style.Styles;

/**
 * The one line that gives an app its colour, and the reason the four examples here have one.
 *
 * <p><b>An app declares its brand; it does not pick colours.</b> One seed goes in, and CrossFly
 * derives the whole scheme from it — primary, surface, on-surface, containers, the tonal fills
 * behind an active tab, the ring of a gauge, both themes. Nothing downstream names a colour, which
 * is what makes dark mode and the skin picker work without a single branch.</p>
 *
 * <p>Skip this and the app is not "unstyled", it is <b>grey</b>: the skin's neutral palette is what
 * you get when nobody declared anything, and it looks exactly like a bug. That is what happened to
 * these four — they were correct from the samples menu, which declares a brand of its own, and
 * monochrome when launched straight from {@code classname}, which is how anybody copying them would
 * run them.</p>
 *
 * <h3>Why enter/leave and not just a call at startup</h3>
 *
 * <p>The brand is global state. A real app declares it once in {@code Main} and never thinks about
 * it again — and if that is your case, {@code Styles.setBrand(BRAND)} at startup is the whole story
 * and this class is more machinery than you need.</p>
 *
 * <p>These four are different: they are guests. They open from a menu that has its own brand, and
 * from a test suite whose later screenshots are compared against committed images. A guest that
 * changes global state and walks away leaves its host repainted in someone else's colour — the
 * samples menu comes back blue, the goldens fail three tests later, and neither failure points at
 * the app that caused it. So each app calls {@link #enter()} when it opens and {@link #leave()}
 * when it closes, and the palette that was in force is handed back.</p>
 */
public final class AppTheme
{
   /**
    * The seed every colour in the four example apps is derived from.
    *
    * <p>Deep blue, chosen against orange for one reason: orange is the brand of the production app
    * these examples were extracted from, and an example that wears it invites the reader to think
    * the two are related. For orange instead, this is the only line to change —
    * {@code 0xFFE2622C} is a good one. Nothing else in the four apps names a colour.</p>
    */
   public static final int BRAND = 0xFF1565C0;

   /**
    * The page behind the cards: the surface, pulled a little way towards the brand.
    *
    * <p><b>Why a page needs its own colour at all.</b> Cards, buttons and fields are all drawn on
    * the skin's surface, which in a light theme is white or near it. Put them on a white page and
    * every edge disappears — the card stops reading as a card and a white button on white looks
    * like a mistake rather than a control. What separates them is not a border, it is the page
    * being a shade the foreground is not.</p>
    *
    * <p><b>Why it is derived and not a hex.</b> {@code 0xFFF2F5F9} would be a perfectly nice light
    * blue and would be wrong twice: it stops being light blue the moment somebody changes
    * {@link #BRAND} to orange, and it stays light in dark mode, where the page must go darker than
    * the cards rather than lighter. Mixing the primary into the surface at 8% follows both — the
    * tint always matches the brand, and in a dark theme the same call yields a deep, slightly
    * tinted ground.</p>
    *
    * <p>Read it through {@code onRestyle} rather than once, so a theme flip re-derives it.</p>
    *
    * @return the ARGB page colour for the palette currently in force
    */
   public static int pageColor()
   {
      return dev.cf.Tones.page();
   }

   /** What was in force before the outermost {@link #enter()}. */
   private static Integer previousBrand;
   private static ColorScheme previousScheme;

   /** How many example apps are open. Restores only when the last one closes. */
   private static int depth;

   private AppTheme()
   {
   }

   /**
    * Declares {@link #BRAND}, remembering whatever was in force.
    *
    * <p>Call it first thing in {@code onAdd}. It triggers a restyle, so every window already open
    * re-resolves its colours — including this one, which is why it can run before the screen is
    * built.</p>
    */
   public static void enter()
   {
      if (depth++ == 0)
      {
         previousBrand = Styles.hasBrand() ? Integer.valueOf(Styles.brand()) : null;
         ColorScheme live = Styles.colorScheme();
         if (live != null)
         {
            // A SNAPSHOT, never the reference. Styles keeps ONE ColorScheme instance for the life
            // of the app and mutates its roles in place on every seed change — deliberately, so
            // that a PalColor captured into a style at build time keeps resolving and follows the
            // theme forever. The consequence for anybody saving and restoring: the object you kept
            // IS the object about to be overwritten, so handing it back restores nothing at all.
            // This cost four broken golden images in a suite that had been green all morning.
            previousScheme = ColorScheme.fromSeed(BRAND, live.isDark);
            previousScheme.copyFrom(live);
         }
      }
      Styles.setBrand(BRAND);
   }

   /**
    * Hands the palette back to the host.
    *
    * <p>Call it from {@code onRemove}, which CrossFly runs on {@code hide()}. Two cases: the host
    * had declared a brand, so re-declaring it regenerates everything; or nothing was declared, so
    * the declaration is dropped <b>and</b> the scheme that was actually in force is restored —
    * clearing the brand alone would leave this app's blue behind, still painted, just no longer
    * owned by anybody.</p>
    */
   public static void leave()
   {
      if (depth > 0 && --depth == 0)
      {
         if (previousBrand != null)
            Styles.setBrand(previousBrand.intValue());
         else
         {
            Styles.clearBrand();
            if (previousScheme != null)
               Styles.setColorScheme(previousScheme);
         }
         previousBrand = null;
         previousScheme = null;
      }
   }
}
```
