यह क्या दिखाता है
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 का अनुमान लगाने के बजाय उदाहरण पढ़ सके।