它展示了什么
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 theOrderjavadoc 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.
StepTraildraws 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.
在你的机器上运行
每个示例都是普通的 Window,启动器只需要它的名字。
gradlew lwjgl3:run --args="classname dev.cf.app.wizard.WizardApp"Java 是提前编译的,浏览器无法自行重建你的修改 —— 这里的代码在发布前已由 TeaVM 转成 JavaScript。把文件复制到项目里,它在四个平台上原样运行;上面那条命令就是全部准备工作。
它用到了什么
本示例触及的框架类型。带链接的有参考页。
源码
完整无删减。这就是那个文件,不是片段。
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();
}
}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();
}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;
}
}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 };
}
}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;
}
}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" };
}
}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];
}
}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);
}
}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;
}
}
}本页的机器可读版本,让模型直接读取示例集,而不是猜测 API。