O que isto mostra
Depot — the offline-first CRUD app, and the entry point of this example.
gradlew lwjgl3:run --args="classname dev.cf.app.crud.CrudApp"
The shape
One shell Window hosting four destinations, three of which are listings over a local SQLite database, plus a detail overlay and a long form. It is the shape most business apps end up being, and the four decisions below are the ones that are expensive to change later.
1 · The shell is one Window; the destinations are Components
TabBar hosts them and keeps them alive across switches, so a tab the user comes back to
is where they left it. Overlays — PartDetailScreen, PartFormScreen — are separate
Windows shown on top (new X().show(), back is hide()). The rule that follows from
this is short: a thing you navigate away from is a Window; a thing you switch between is
not.
2 · The store is the source of truth, and it is local
Every screen reads Repository and nothing else. There is no in-memory cache next to it,
because two caches disagree the moment one screen writes. After a write, screens call
refreshAll() and re-read — which is cheap, correct, and does not need a change-event
system to be invented for it.
3 · The first run has a screen of its own
FirstLoadScreen holds navigation while the store fills, and it is the only screen that
can fail visibly. Every other screen in the app is instant by construction.
4 · Unsent work is visible
A local edit shows a "not sent" badge on the card, on the detail and as a number on
OutboxTab. The user never has to guess whether something arrived.
What is deliberately missing
Login, permissions, a real transport, conflict resolution, and paging. Each is a genuine
subject and each would double this file's size without teaching anything about the shape. The
seam for the transport is flushOutbox(); the seam for paging is
Items, which counts and renders rather than taking a list, precisely so a listing
can show more than it has loaded.
Rodar na sua máquina
Todo exemplo é uma Window comum, então o launcher só precisa do nome dela.
gradlew lwjgl3:run --args="classname dev.cf.app.crud.CrudApp"Java é compilado antes de rodar, então o navegador não consegue reconstruir uma edição sozinho — o código que você vê aqui virou JavaScript pelo TeaVM antes de ser publicado. Copie o arquivo para um projeto e ele roda sem mudanças nas quatro plataformas; o comando acima é toda a preparação.
O que ele usa
Tipos do framework que este exemplo toca. Os que têm link têm página de referência.
O código
Completo, sem cortes. É o arquivo, não um trecho dele.
package dev.cf.app.crud;
import dev.cf.Described;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.skin.Skin;
import dev.crossfly.ui.style.*;
/**
* <b>Depot</b> — the offline-first CRUD app, and the entry point of this example.
*
* <pre>
* gradlew lwjgl3:run --args="classname dev.cf.app.crud.CrudApp"
* </pre>
*
* <h2>The shape</h2>
*
* <p>One shell Window hosting four destinations, three of which are listings over a local SQLite
* database, plus a detail overlay and a long form. It is the shape most business apps end up
* being, and the four decisions below are the ones that are expensive to change later.</p>
*
* <h3>1 · The shell is one Window; the destinations are Components</h3>
*
* <p>{@link TabBar} hosts them and keeps them alive across switches, so a tab the user comes back to
* is where they left it. Overlays — {@link PartDetailScreen}, {@link PartFormScreen} — are separate
* Windows shown on top ({@code new X().show()}, back is {@code hide()}). The rule that follows from
* this is short: <b>a thing you navigate away from is a Window; a thing you switch between is
* not.</b></p>
*
* <h3>2 · The store is the source of truth, and it is local</h3>
*
* <p>Every screen reads {@link Repository} and nothing else. There is no in-memory cache next to it,
* because two caches disagree the moment one screen writes. After a write, screens call
* {@link #refreshAll()} and re-read — which is cheap, correct, and does not need a change-event
* system to be invented for it.</p>
*
* <h3>3 · The first run has a screen of its own</h3>
*
* <p>{@link FirstLoadScreen} holds navigation while the store fills, and it is the only screen that
* can fail visibly. Every other screen in the app is instant by construction.</p>
*
* <h3>4 · Unsent work is visible</h3>
*
* <p>A local edit shows a "not sent" badge on the card, on the detail and as a number on
* {@link OutboxTab}. The user never has to guess whether something arrived.</p>
*
* <h2>What is deliberately missing</h2>
*
* <p>Login, permissions, a real transport, conflict resolution, and paging. Each is a genuine
* subject and each would double this file's size without teaching anything about the shape. The
* seam for the transport is {@link Repository#flushOutbox()}; the seam for paging is
* {@link Listing.Items}, which counts and renders rather than taking a list, precisely so a listing
* can show more than it has loaded.</p>
*/
public class CrudApp extends Window implements Described
{
/** Destination indices, in bottom-nav order. */
public static final int TAB_PARTS = 0, TAB_SUPPLIERS = 1, TAB_ORDERS = 2, TAB_OUTBOX = 3;
private static final int IC_PARTS = 0xE1DB, IC_SUPPLIERS = 0xE8D1, IC_ORDERS = 0xE8CC,
IC_OUTBOX = 0xE2C3;
/** Bottom-nav glyphs read better a little larger than body text. */
private static final float NAV_ICON = 1.5f;
private TabBar tabs;
private PartsTab parts;
private SuppliersTab suppliers;
private OrdersTab orders;
private OutboxTab outbox;
@Override
public String title()
{
return "Depot — offline-first CRUD";
}
@Override
public String note()
{
return "One shell, three listings over a local database, a detail and a long form. A"
+ " thing you navigate away from is a Window; a thing you switch between is not.";
}
/** 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));
// Nothing is built until the store answers. The gate runs the check off the paint thread and
// shows itself only when there is work; on a warm store this callback fires within a frame or
// two and the user never sees a loading screen.
FirstLoadScreen.gate(this::buildShell);
}
private void buildShell()
{
try
{
parts = new PartsTab(this);
suppliers = new SuppliersTab(this);
orders = new OrdersTab(this);
outbox = new OutboxTab(this);
tabs = new TabBar(new TabBar.Tab("Parts", MaterialIcon.of(IC_PARTS, NAV_ICON)),
new TabBar.Tab("Suppliers", MaterialIcon.of(IC_SUPPLIERS, NAV_ICON)),
new TabBar.Tab("Orders", MaterialIcon.of(IC_ORDERS, NAV_ICON)),
new TabBar.Tab("Outbox", MaterialIcon.of(IC_OUTBOX, NAV_ICON)));
tabs.setContent(parts, suppliers, orders, outbox);
// Entering a destination re-reads the store: the user may have edited a part two tabs ago,
// and a listing that shows what was true when it was last built is a listing that lies.
tabs.onTabChanged(i -> refreshAll());
// A TabBar is a tab strip by default — tabs on TOP, the desktop idiom. A phone app wants
// them at the bottom, within reach of a thumb, and that is one flag on the skin's own
// style rather than a style of our own: everything else (shape, typography, the tonal pill
// behind the active icon) keeps coming from the skin.
tabs.style(Skin.get()
.tabBar()
.copy()
.tabsAtBottom(true));
tabs.testTag("crud.tabs");
// Opened from a menu, the app needs a way back to it; as the whole app (the root
// window) it has none to go to, and each tab already titles itself.
if (!isRoot())
add(0f, new AppBar("Depot", this::hide));
add(LAY_FILL, tabs);
// The shell was built after onAdd returned, so the layout it already computed is stale.
setValidLayout(false);
Screen.repaint();
updateBadge();
}
catch (Exception e)
{
dev.crossfly.util.Debug.handle(e);
}
}
/**
* Re-reads the store into every destination.
*
* <p>Called after any write, and when a destination is entered. Blunt on purpose: at this size
* a full re-read costs less than the machinery needed to work out which screens a given write
* affected, and it cannot be wrong. Introduce something finer when a measurement, not a
* suspicion, says it is needed.</p>
*/
public void refreshAll()
{
if (parts == null)
return;
parts.reload();
suppliers.reload();
orders.reload();
outbox.reload();
updateBadge();
Screen.repaint();
}
/** Puts the pending count on the Outbox tab, or clears it when there is nothing waiting. */
private void updateBadge()
{
int n = Repository.get()
.pendingCount();
tabs.badge(TAB_OUTBOX, n == 0 ? null : "" + n);
}
/**
* Switches destinations from a child screen.
*
* @param index one of the {@code TAB_*} constants
*/
public void goToTab(int index)
{
tabs.setActiveTab(index);
refreshAll();
}
}package dev.cf.app.crud;
import java.util.ArrayList;
import java.util.List;
import dev.crossfly.device.Device;
import dev.crossfly.sqlite.SqliteCursor;
import dev.crossfly.sqlite.SqliteDb;
import dev.crossfly.util.DateTimeUtils;
/**
* The local database, and the only place in this app that writes SQL.
*
* <p><b>What "offline-first" actually means here.</b> The app never asks the network whether it may
* show something. It reads from the local database, always; a write lands in the local table and,
* in the same transaction, in an {@code outbox} row. The row the user just edited is therefore
* visible immediately and carries a {@code dirty} flag, which the listings and the detail show as
* a "not sent" badge. Sending is a separate, later, failable job — {@link #flushOutbox()} here
* stands in for it — and the interface does not change shape when it succeeds.</p>
*
* <p>That is the whole pattern, and the two halves are easy to get wrong separately:</p>
* <ul>
* <li>Writing local but showing the server's copy makes the user's own edit vanish for a second.</li>
* <li>Writing local without an outbox makes "did this reach the server?" unanswerable, so the app
* ends up re-uploading everything or losing edits.</li>
* </ul>
*
* <p>The row shapes are nested classes rather than files of their own on purpose: they exist to
* carry what the store returns, they change whenever the schema does, and keeping them next to the
* SQL that fills them is what keeps the two in step.</p>
*
* <p>There is no ORM and no generated code. {@link SqliteDb} is the whole data layer: {@code exec}
* for DDL, {@code execute} for a parameterised write, {@code query} for a cursor. Columns are read
* 0-based, in the order the SELECT names them — which is why every SELECT in this class lists its
* columns explicitly and none of them says {@code *}.</p>
*/
public class Repository
{
/** A part in the catalogue — the entity the listing, the detail and the form all work on. */
public static class Part
{
/** Local id; also the server id once the row has been sent. */
public long id;
/** Short human code, unique in the catalogue ("BRG-204"). */
public String code;
public String name;
public String category;
public long supplierId;
/** Price in cents — never a float; see {@link #money(long)}. */
public long priceCents;
public int stock;
public String notes;
/** True while this row has a local change that has not been sent. */
public boolean dirty;
}
/** A supplier. Deliberately thin: the second listing exists to show a second shape, not a second domain. */
public static class Supplier
{
public long id;
public String name;
public String city;
/** How many parts reference this supplier — computed by the query, not stored. */
public int partCount;
}
/** A purchase order. The third listing, and the one with a status worth colouring. */
public static class Purchase
{
public long id;
public String ref;
public String supplierName;
public String placedOn;
public long totalCents;
/** One of {@link #STATUS}. */
public String status;
public boolean dirty;
}
/** The statuses a purchase can carry, in lifecycle order. */
public static final String[] STATUS = { "draft", "placed", "shipped", "received" };
/** The categories the form offers. A real app would read these from the store too. */
public static final String[] CATEGORY = { "Bearings", "Fasteners", "Seals", "Hydraulics",
"Electrical", "Tooling" };
private static Repository instance;
private SqliteDb db;
private Repository()
{
}
/**
* The one instance. A store is a process-wide resource: opening a second connection to the same
* file from a second screen is how an app ends up with two caches that disagree.
*
* @return the repository
*/
public static Repository get()
{
if (instance == null)
instance = new Repository();
return instance;
}
/** Whether {@link #open()} has run and the store is usable. */
public boolean isOpen()
{
return db != null;
}
/**
* Opens the database, creating the schema on first run.
*
* <p>Separate from {@link #seed()} because the two fail differently and the gate screen has to
* tell them apart: a schema that will not create is a bug, an empty store is a first run.</p>
*
* @throws Exception if the database cannot be opened or the schema cannot be created
*/
public void open() throws Exception
{
if (db != null)
return;
String base = Device.homeFolder;
db = SqliteDb.open(base == null || base.isEmpty() ? "cf-depot.db" : base + "/cf-depot.db");
db.exec("PRAGMA journal_mode=WAL");
db.exec("CREATE TABLE IF NOT EXISTS part("
+ "id INTEGER PRIMARY KEY, code TEXT, name TEXT, category TEXT, supplier_id INTEGER,"
+ " price_cents INTEGER, stock INTEGER, notes TEXT, dirty INTEGER)");
db.exec("CREATE TABLE IF NOT EXISTS supplier("
+ "id INTEGER PRIMARY KEY, name TEXT, city TEXT)");
db.exec("CREATE TABLE IF NOT EXISTS purchase("
+ "id INTEGER PRIMARY KEY, ref TEXT, supplier_id INTEGER, placed_on TEXT,"
+ " total_cents INTEGER, status TEXT, dirty INTEGER)");
// The outbox is append-only and keyed by nothing but its own order: what happened, to what,
// and when. A row leaves it only when the server has confirmed it.
db.exec("CREATE TABLE IF NOT EXISTS outbox("
+ "seq INTEGER PRIMARY KEY AUTOINCREMENT, entity TEXT, entity_id INTEGER, op TEXT,"
+ " queued_at TEXT)");
}
/** Whether the store already holds a catalogue — the question the load gate asks. */
public boolean isEmpty()
{
return db.queryLong("SELECT COUNT(*) FROM part", 0) == 0;
}
/**
* Fills an empty store with a catalogue.
*
* <p>In a real app this is the first sync: a paged download that can fail halfway, which is why
* it runs inside one transaction. Here it is generated locally, so the example has no endpoint
* to depend on — but the shape (one transaction, all or nothing) is the part worth copying.</p>
*
* @param onProgress called with 0..1 as the load advances, from the calling thread
*/
public void seed(final ProgressListener onProgress)
{
db.transaction(() ->
{
for (int i = 0; i < SUPPLIER_NAME.length; i++)
db.execute("INSERT INTO supplier(id,name,city) VALUES(?,?,?)", i + 1L, SUPPLIER_NAME[i],
SUPPLIER_CITY[i]);
for (int i = 0; i < PART_NAME.length; i++)
{
db.execute(
"INSERT INTO part(id,code,name,category,supplier_id,price_cents,stock,notes,dirty)"
+ " VALUES(?,?,?,?,?,?,?,?,0)",
i + 1L, code(i), PART_NAME[i], CATEGORY[i % CATEGORY.length],
(long) (i % SUPPLIER_NAME.length) + 1, 1250L + i * 337L % 48000L, 4 + i * 7 % 190,
"");
if (onProgress != null)
onProgress.at((i + 1f) / (PART_NAME.length + PURCHASE_REF.length));
}
for (int i = 0; i < PURCHASE_REF.length; i++)
{
db.execute("INSERT INTO purchase(id,ref,supplier_id,placed_on,total_cents,status,dirty)"
+ " VALUES(?,?,?,?,?,?,0)", i + 1L, PURCHASE_REF[i],
(long) (i % SUPPLIER_NAME.length) + 1, PURCHASE_DATE[i], 84000L + i * 21700L,
STATUS[i % STATUS.length]);
if (onProgress != null)
onProgress.at((PART_NAME.length + i + 1f) / (PART_NAME.length + PURCHASE_REF.length));
}
});
}
/** Reports how far a long load has come. */
public interface ProgressListener
{
/**
* @param fraction how much is done, 0..1
*/
void at(float fraction);
}
// ───────────────────────────────── Reading ─────────────────────────────────
/**
* The catalogue, optionally narrowed by a search term.
*
* <p>The search is a LIKE over code and name. That is the right amount of machinery for a few
* thousand rows on a phone; past that the answer is an FTS5 table, not a cleverer LIKE.</p>
*
* @param term what the user typed, or null/empty for everything
* @return the matching parts, ordered by name
*/
public List<Part> parts(String term)
{
String sql = "SELECT id,code,name,category,supplier_id,price_cents,stock,notes,dirty"
+ " FROM part";
List<Part> out = new ArrayList<>();
boolean filtered = term != null && !term.trim().isEmpty();
String like = filtered ? "%" + term.trim() + "%" : null;
try (SqliteCursor c = filtered
? db.query(sql + " WHERE code LIKE ? OR name LIKE ? ORDER BY name", like, like)
: db.query(sql + " ORDER BY name"))
{
while (c.next())
out.add(readPart(c));
}
return out;
}
/**
* One part by id.
*
* @param id the part id
* @return the part, or null if it is not in the store
*/
public Part part(long id)
{
try (SqliteCursor c = db.query(
"SELECT id,code,name,category,supplier_id,price_cents,stock,notes,dirty"
+ " FROM part WHERE id=?", id))
{
return c.next() ? readPart(c) : null;
}
}
/**
* The suppliers, with the part count computed by the query.
*
* @param term what the user typed, or null/empty for everything
* @return the matching suppliers, ordered by name
*/
public List<Supplier> suppliers(String term)
{
// LEFT JOIN, not JOIN: a supplier with no parts is still a supplier, and dropping it silently
// is the classic way a listing ends up with fewer rows than the count above it claims.
String sql = "SELECT s.id, s.name, s.city, COUNT(p.id)"
+ " FROM supplier s LEFT JOIN part p ON p.supplier_id = s.id";
boolean filtered = term != null && !term.trim().isEmpty();
String like = filtered ? "%" + term.trim() + "%" : null;
List<Supplier> out = new ArrayList<>();
try (SqliteCursor c = filtered
? db.query(sql + " WHERE s.name LIKE ? OR s.city LIKE ? GROUP BY s.id ORDER BY s.name",
like, like)
: db.query(sql + " GROUP BY s.id ORDER BY s.name"))
{
while (c.next())
{
Supplier s = new Supplier();
s.id = c.getLong(0);
s.name = c.getString(1);
s.city = c.getString(2);
s.partCount = c.getInt(3);
out.add(s);
}
}
return out;
}
/**
* The purchase orders, newest first.
*
* @param term what the user typed, or null/empty for everything
* @return the matching purchases
*/
public List<Purchase> purchases(String term)
{
String sql = "SELECT o.id, o.ref, s.name, o.placed_on, o.total_cents, o.status, o.dirty"
+ " FROM purchase o LEFT JOIN supplier s ON s.id = o.supplier_id";
boolean filtered = term != null && !term.trim().isEmpty();
String like = filtered ? "%" + term.trim() + "%" : null;
List<Purchase> out = new ArrayList<>();
try (SqliteCursor c = filtered
? db.query(sql + " WHERE o.ref LIKE ? OR s.name LIKE ? ORDER BY o.placed_on DESC", like,
like)
: db.query(sql + " ORDER BY o.placed_on DESC"))
{
while (c.next())
{
Purchase p = new Purchase();
p.id = c.getLong(0);
p.ref = c.getString(1);
p.supplierName = c.getString(2);
p.placedOn = c.getString(3);
p.totalCents = c.getLong(4);
p.status = c.getString(5);
p.dirty = c.getInt(6) != 0;
out.add(p);
}
}
return out;
}
/**
* A supplier's name, for a screen that has only the id.
*
* @param id the supplier id
* @return the name, or a dash when the id points at nothing
*/
public String supplierName(long id)
{
String n = db.queryString("SELECT name FROM supplier WHERE id=?", id);
return n == null ? "—" : n;
}
/** How many local changes are waiting to be sent. */
public int pendingCount()
{
return (int) db.queryLong("SELECT COUNT(*) FROM outbox", 0);
}
// ───────────────────────────────── Writing ─────────────────────────────────
/**
* Saves a part — insert when {@code id} is 0, update otherwise — and queues it for sending.
*
* <p>The table write and the outbox row are one transaction. If they were two, a crash between
* them would leave either an edit nobody will ever send or a promise to send an edit that does
* not exist.</p>
*
* @param p the part to save; its {@code id} is filled in on insert
*/
public void save(final Part p)
{
db.transaction(() ->
{
boolean inserting = p.id == 0;
if (inserting)
// A locally-created row needs an id the server has not issued yet. Counting down from
// a high number keeps it clearly distinct from a server id in every log and screen.
p.id = db.queryLong("SELECT COALESCE(MIN(id),1000000000) - 1 FROM part WHERE id > 900000000",
999999999L);
p.dirty = true;
if (inserting)
db.execute("INSERT INTO part(id,code,name,category,supplier_id,price_cents,stock,notes,"
+ "dirty) VALUES(?,?,?,?,?,?,?,?,1)", p.id, p.code, p.name, p.category, p.supplierId,
p.priceCents, p.stock, p.notes);
else
db.execute("UPDATE part SET code=?,name=?,category=?,supplier_id=?,price_cents=?,"
+ "stock=?,notes=?,dirty=1 WHERE id=?", p.code, p.name, p.category, p.supplierId,
p.priceCents, p.stock, p.notes, p.id);
queue("part", p.id, inserting ? "insert" : "update");
});
}
/**
* Deletes a part and queues the deletion.
*
* @param id the part id
*/
public void delete(final long id)
{
db.transaction(() ->
{
db.execute("DELETE FROM part WHERE id=?", id);
queue("part", id, "delete");
});
}
/**
* Clears the outbox and the dirty flags, as a successful send would.
*
* <p>This is where the real transport goes. It is a method and not a comment because the
* interesting part is what it does on success — clearing both halves, and only after the server
* confirmed — and that is the same whatever the transport is.</p>
*
* @return how many queued operations were cleared
*/
public int flushOutbox()
{
final int[] n = new int[1];
db.transaction(() ->
{
n[0] = pendingCount();
db.exec("DELETE FROM outbox");
db.exec("UPDATE part SET dirty=0");
db.exec("UPDATE purchase SET dirty=0");
});
return n[0];
}
/** Appends one operation to the outbox. Always called from inside a transaction. */
private void queue(String entity, long id, String op)
{
db.execute("INSERT INTO outbox(entity,entity_id,op,queued_at) VALUES(?,?,?,?)", entity, id,
op, DateTimeUtils.timestampCompact());
}
private static Part readPart(SqliteCursor c)
{
Part p = new Part();
p.id = c.getLong(0);
p.code = c.getString(1);
p.name = c.getString(2);
p.category = c.getString(3);
p.supplierId = c.getLong(4);
p.priceCents = c.getLong(5);
p.stock = c.getInt(6);
p.notes = c.getString(7);
p.dirty = c.getInt(8) != 0;
return p;
}
/**
* Formats cents as money.
*
* <p>Money is carried as a long of cents everywhere in this app and converted only here. A
* {@code float} price is wrong by a fraction of a cent per operation, and a listing that sums a
* few hundred of them shows a total that does not match the rows above it.</p>
*
* @param cents the amount in cents
* @return the formatted amount
*/
public static String money(long cents)
{
long c = cents < 0 ? -cents : cents;
return (cents < 0 ? "-" : "") + c / 100 + "." + (c % 100 < 10 ? "0" : "") + c % 100;
}
/** Closes the store. Called when the app shuts down, not between screens. */
public void close()
{
if (db != null)
{
db.close();
db = null;
}
}
// ─────────────────────────── The seed catalogue ───────────────────────────
private static String code(int i)
{
String s = "" + (100 + i);
return PART_NAME[i].substring(0, 3).toUpperCase() + "-" + s;
}
private static final String[] SUPPLIER_NAME = { "Northgate Industrial", "Bellamy Supply",
"Kestrel Components", "Halden & Roe", "Ironvale Trading", "Meridian Parts" };
private static final String[] SUPPLIER_CITY = { "Leeds", "Antwerp", "Porto", "Turin", "Malmö",
"Bristol" };
private static final String[] PART_NAME = { "Ball bearing 6204", "Roller bearing 30206",
"Hex bolt M10", "Socket screw M6", "Lock washer 10mm", "Oil seal 35x52", "O-ring 22x3",
"Gasket set A", "Hydraulic hose 1/2", "Quick coupler 3/8", "Pressure valve 40bar",
"Filter cartridge 10µ", "Relay 24V", "Contactor 40A", "Cable gland M20", "Terminal block 12p",
"Drill bit 8mm", "End mill 6mm", "Tap M8", "Caliper 150mm", "Thrust bearing 51105",
"Flange nut M12", "V-ring seal 40", "Return hose 3/4", "Solenoid 12V", "Reamer 10mm" };
private static final String[] PURCHASE_REF = { "PO-4181", "PO-4180", "PO-4176", "PO-4172",
"PO-4169", "PO-4165", "PO-4160", "PO-4158" };
private static final String[] PURCHASE_DATE = { "2026-08-28", "2026-08-27", "2026-08-21",
"2026-08-14", "2026-08-09", "2026-08-02", "2026-07-24", "2026-07-19" };
}package dev.cf.app.crud;
import dev.cf.app.AppTheme;
import dev.crossfly.Main;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.skin.Skin;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* The load gate: the screen that stands between opening the app and using it, on the one run where
* there is nothing stored yet.
*
* <p><b>Why an offline-first app needs a gate at all.</b> Every other screen in this app reads the
* local store and is therefore instant. Exactly once — the first run on a new device — the store is
* empty, and the honest thing to show is not an empty listing. An empty listing is indistinguishable
* from "your catalogue is gone", and that is the reading a user reaches for first.</p>
*
* <p>So the gate holds navigation until the store has something in it, and says what it is doing.
* Three properties matter more than how it looks:</p>
*
* <ul>
* <li><b>The work is on a worker thread.</b> A load that runs on the paint thread freezes the very
* spinner that exists to say the app is alive. The worker talks back through
* {@link Main#runOnPaintThread(Runnable)} — touching a component from another thread is the
* single most common way a CrossFly screen corrupts its layout.</li>
* <li><b>Failure is a state, not a crash.</b> There is no network here to fail, so the
* {@code -Dcf.crud.failload=1} flag simulates one. What it demonstrates is the layout: the
* same screen, a different message, and a retry — never a dead end and never a dialog over a
* blank screen.</li>
* <li><b>There is no "skip".</b> Without data the app has nothing to show, so an escape hatch
* would only move the empty listing one tap later. When a load IS optional, the button
* belongs here; when it is not, offering it is a lie.</li>
* </ul>
*/
public class FirstLoadScreen extends Window
{
/** Set {@code -Dcf.crud.failload=1} to see the failure branch without breaking anything. */
private static final String FAIL_FLAG = "cf.crud.failload";
private final Runnable onDone;
private Label title, detail;
private Progress bar;
private Button retry;
/**
* @param onDone runs on the paint thread once the store is ready
*/
public FirstLoadScreen(Runnable onDone)
{
this.onDone = onDone;
}
/**
* Opens the store and shows this gate <b>only if there is work for it</b>.
*
* <p>This is the entry point an app actually calls, and the reason it exists is that the gate
* must not appear on every launch. Second run onwards the store is warm, the check costs a file
* open, and flashing a progress screen for 40 ms reads as a stutter rather than as
* reassurance. When the check itself fails, the gate <i>is</i> shown — it is the screen that
* knows how to say so and how to retry.</p>
*
* @param onDone runs on the paint thread once the store is ready
*/
public static void gate(final Runnable onDone)
{
new Thread(() ->
{
boolean ready;
try
{
Repository repo = Repository.get();
repo.open();
ready = !repo.isEmpty() && !"1".equals(System.getProperty(FAIL_FLAG));
}
catch (Exception e)
{
dev.crossfly.util.Debug.log("[crud] warm start check failed: " + e);
ready = false;
}
final boolean warm = ready;
Main.runOnPaintThread(() ->
{
try
{
if (warm)
onDone.run();
else
new FirstLoadScreen(onDone).show();
}
catch (Exception e)
{
dev.crossfly.util.Debug.handle(e);
}
});
}, "cf-crud-warm-check").start();
}
@Override
public void onAdd(boolean firstTime) throws Exception
{
if (!firstTime)
return;
// 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.CENTER, Position.CENTER));
add(LAY_AUTO, outgap(matglyph(0xE1DB, Styles.primaryColor, 3f), 0, 0, 0, 1.2f));
title = label("Preparing your catalogue", 1.5f, true, Styles.textColor, Position.CENTER);
title.testTag("load.title");
add(LAY_AUTO, outgap(title, 0, 0, 0, .5f));
detail = label("This happens once, on this device.", 1f, false, Styles.captionColor,
Position.CENTER);
detail.testTag("load.detail");
add(LAY_AUTO, outgap(detail, 0, 0, 0, 1.4f));
// A DETERMINATE bar, because this load knows its own size. An indeterminate spinner over a
// load whose length is known throws away the only reassurance the user actually wants.
bar = new Progress(0f, 1f);
bar.style(Skin.get().progress().autoWidth(Relative.ofFont(16f)));
bar.testTag("load.bar");
add(LAY_AUTO, bar);
retry = new Button("Try again");
retry.testTag("load.retry");
retry.onAction(c -> start());
retry.setVisible(false);
add(LAY_AUTO, outgap(retry, 0, 1.4f, 0, 0));
start();
}
/** Runs the load on a worker thread, reporting back to the paint thread. */
private void start()
{
retry.setVisible(false);
say("Preparing your catalogue", "This happens once, on this device.");
new Thread(this::load, "cf-crud-first-load").start();
}
private void load()
{
try
{
Repository repo = Repository.get();
repo.open();
if ("1".equals(System.getProperty(FAIL_FLAG)))
throw new IllegalStateException("simulated first-load failure");
if (repo.isEmpty())
repo.seed(f -> Main.runOnPaintThread(() ->
{
bar.setValue(f);
Screen.repaint();
}));
Main.runOnPaintThread(() ->
{
try
{
hide();
onDone.run();
}
catch (Exception e)
{
dev.crossfly.util.Debug.handle(e);
}
});
}
catch (Exception e)
{
dev.crossfly.util.Debug.log("[crud] first load failed: " + e);
Main.runOnPaintThread(() ->
{
// What went wrong, and what the user can do about it — in that order, and without the
// exception text, which tells them nothing they can act on.
say("We could not load your catalogue",
"Check your connection and try again. Nothing was lost.");
bar.setValue(0f);
retry.setVisible(true);
Screen.repaint();
});
}
}
private void say(String t, String d)
{
title.setText(t);
detail.setText(d);
title.setValidLayout(false);
detail.setValidLayout(false);
}
}package dev.cf.app.crud;
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.*;
/**
* What the three listings have in common: a search box, a {@link Listing}, an empty state, and a
* reload that does not throw the reader's place away.
*
* <p><b>A destination is a Component, not a Window.</b> The shell is one Window
* ({@link CrudApp}); each bottom-nav destination is a component the {@link TabBar} hosts and keeps
* alive. Making them Windows instead is the mistake that costs the most later: each would push its
* own back-stack entry, the hardware Back key would walk backwards through tabs, and switching tabs
* would rebuild state the user expects to find where they left it.</p>
*
* <p><b>The build happens in {@link #onLayout()}, not in the constructor.</b> A tab is constructed
* before it has a size and, in this app, before the store is guaranteed to be open. The framework
* idiom for "content that comes from data" is a {@code dirty} flag consumed by {@code onLayout} —
* the same one {@link CardGrid} uses internally. Building in the constructor gives you an empty
* container that looks like a data bug.</p>
*
* <p>Subclasses supply three things: a title, a placeholder for the search box, and the items.</p>
*/
public abstract class ListingTab extends Component
{
/** The shell, for opening overlays and refreshing the pending badge. */
protected final CrudApp app;
/** What the user last typed in the search box. */
protected String term = "";
/** The listing; null until {@link #onLayout()} has built it once. */
protected Listing listing;
private boolean dirty = true;
private Label counter;
/**
* @param app the shell that hosts this tab
*/
protected ListingTab(CrudApp app)
{
this.app = app;
}
/** The tab's heading. */
protected abstract String title();
/** The search box placeholder — it should name the fields the search actually covers. */
protected abstract String searchHint();
/** A stable prefix for this tab's test tags, e.g. {@code "parts"}. */
protected abstract String tag();
/**
* Re-reads the store with the current {@link #term} and hands the result to the listing.
*
* @return the items for the listing to render
*/
protected abstract Listing.Items load();
/** Declares the table columns. Called once, when the listing is built. */
protected abstract void columns(Listing l);
/** How many items the last {@link #load()} returned — for the counter line. */
protected abstract int count();
@Override
public void onLayout() throws Exception
{
if (dirty)
build();
super.onLayout();
}
/** Rebuilds from the store — call after any write, or when the tab is entered. */
public void reload()
{
if (listing == null)
return;
listing.items(load());
// refresh(true): the reader keeps their scroll position. A listing is rebuilt for reasons
// that are not the reader's — a write elsewhere, new data arriving — and being sent back to
// the top for someone else's reason reads as the app losing its place.
listing.refresh(true);
updateCounter();
}
private void build() throws Exception
{
removeAll();
// removeAll() also drops the layout: it is setLayout(null) underneath, and without this line
// the container falls back to RandomLayout and throws on the first weight it meets.
setLayout(new VerticalLayout(Position.START, Position.START));
getStyle().ingap(Gap.ofFont(1f, .8f, 1f, 0));
Component head = new Component();
head.setLayout(new GridLayout(Position.START, Position.CENTER));
Label t = label(title(), 1.5f, true, Styles.textColor, Position.START);
t.testTag(tag() + ".title");
counter = label("", 1f, false, Styles.captionColor, Position.END);
counter.testTag(tag() + ".count");
head.add(1f, 0f, t, counter);
add(LAY_AUTO, outgap(head, 0, 0, 0, .6f));
SearchField search = new SearchField();
search.caption(searchHint());
search.leftIcon(MaterialIcon.of(0xE8B6)); // search
search.testTag(tag() + ".search");
search.onTextChanged(() ->
{
term = search.getText();
reload();
Screen.repaint();
});
add(LAY_AUTO, outgap(search, 0, 0, 0, .6f));
listing = new Listing().cardWidth(21f)
.columns(1, 3)
.items(load())
.empty(emptyState());
columns(listing);
listing.testTag(tag() + ".listing");
// The mode switch belongs beside the heading, not inside the listing: the widget offers the
// control, the screen decides where it sits.
Component modeRow = new Component();
modeRow.setLayout(new GridLayout(Position.START, Position.CENTER));
modeRow.add(1f, 0f, new Component(), listing.modeButton());
add(LAY_AUTO, outgap(modeRow, 0, 0, 0, .4f));
add(LAY_FILL, listing);
updateCounter();
dirty = false;
}
/**
* What the listing shows when the search matched nothing.
*
* <p>Worth building on purpose. The default of an empty listing is blank space, and blank space
* after a search reads as a broken screen rather than as an answer.</p>
*
* @return the empty-state component
* @throws Exception if it cannot be built
*/
protected Component emptyState() throws Exception
{
Component c = new Component();
c.setLayout(new VerticalLayout(Position.CENTER, Position.CENTER));
c.add(LAY_AUTO, outgap(matglyph(0xE8B6, Styles.captionColor, 2.4f), 0, 2f, 0, .6f));
Label l = label("Nothing matches that.", 1f, false, Styles.captionColor, Position.CENTER);
l.testTag(tag() + ".empty");
c.add(LAY_AUTO, l);
return c;
}
private void updateCounter()
{
if (counter == null)
return;
int n = count();
counter.setText(n + (n == 1 ? " item" : " items"));
counter.setValidLayout(false);
}
/**
* The "not sent yet" mark.
*
* <p>Every screen that shows a row that can be edited offline shows this the same way, which is
* why it is one method: a user learns one mark, not three.</p>
*
* @param pending whether the row has unsent local changes
* @return the badge, or null when there is nothing pending
*/
protected static Badge pendingBadge(boolean pending)
{
return pending ? toneBadge(secondary(), "not sent") : null;
}
}package dev.cf.app.crud;
import dev.cf.Tones;
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.List;
import static dev.crossfly.ui.Widgets.*;
/**
* The catalogue: the app's main listing, and the only one that writes.
*
* <p>It is worth reading this one against {@link SuppliersTab} and {@link OrdersTab}. All three
* are the same twenty lines of scaffolding from {@link ListingTab} and differ only in the query,
* the card and the columns — which is the point of having a base class at all, and the reason a
* fourth listing costs an afternoon rather than a week.</p>
*
* <p>The card carries a "not sent" badge whenever the row has a local edit still in the outbox.
* Showing it on the card and not only on the detail is deliberate: the user who made the edit is
* looking at the list, and a state they have to open a screen to discover is a state they will not
* discover.</p>
*/
public class PartsTab extends ListingTab
{
private List<Repository.Part> rows;
/**
* @param app the shell
*/
public PartsTab(CrudApp app)
{
super(app);
}
protected String title()
{
return "Parts";
}
protected String searchHint()
{
return "Code or name…";
}
protected String tag()
{
return "parts";
}
protected int count()
{
return rows == null ? 0 : rows.size();
}
protected void columns(Listing l)
{
l.column("code", "Code", 8f)
.column("name", "Part", 16f)
.column("category", "Category", 10f)
.column("stock", "Stock", 6f, Position.END)
.column("price", "Price", 8f, Position.END)
// The frozen column is the one that says whose row you are reading when the table is
// scrolled to its right-hand end. Freeze the identity, never the number.
.freeze(Position.START, "code");
}
protected Listing.Items load()
{
rows = Repository.get()
.parts(term);
return new Listing.Items()
{
public int count()
{
return rows.size();
}
public Component card(int i) throws Exception
{
return buildCard(rows.get(i));
}
public void row(DataGrid.Row r, int i) throws Exception
{
Repository.Part p = rows.get(i);
r.cell(0, label(p.code, 1f, true, Styles.textColor, Position.START));
r.cell(1, label(p.name, 1f, false, Styles.textColor, Position.START));
r.cell(2, label(p.category, 1f, false, Styles.captionColor, Position.START));
r.cell(3, label("" + p.stock, 1f, false, stockColor(p.stock), Position.END));
r.cell(4, label(Repository.money(p.priceCents), 1f, false, Styles.textColor,
Position.END));
}
};
}
private Component buildCard(final Repository.Part p) throws Exception
{
Card c = new Card();
c.setLayout(new VerticalLayout(Position.START, Position.START));
Label name = label(p.name, 1.2f, true, Styles.textColor, Position.START);
Badge pending = pendingBadge(p.dirty);
if (pending == null)
// No badge, no row: the name goes straight in. The earlier version always built a grid and
// padded the empty side with a bare `new Component()` — a container with no layout, whose
// measured size a GridLayout column cannot resolve. The whole row came out zero-high, so
// every card silently lost its title while the test still passed: the tag existed, the
// card had children, and nothing in a structural check asks whether a line has HEIGHT.
c.add(LAY_AUTO, outgap(name, 0, 0, 0, .2f));
else
{
Component head = new Component();
head.setLayout(new GridLayout(Position.START, Position.CENTER));
head.add(1f, 0f, name, pending);
c.add(LAY_AUTO, outgap(head, 0, 0, 0, .2f));
}
c.add(LAY_AUTO, outgap(label(p.code + " · " + p.category, 1f, false, Styles.captionColor,
Position.START), 0, 0, 0, .5f));
Component foot = new Component();
foot.setLayout(new GridLayout(Position.START, Position.CENTER));
foot.add(0f, 1f, label(Repository.money(p.priceCents), 1.2f, true, Tones.accent(),
Position.START), label(p.stock + " in stock", 1f, p.stock < 12, stockColor(p.stock),
Position.END));
c.add(LAY_AUTO, foot);
// The whole card opens the detail. A card with one tappable word inside it is a target the
// user misses; the card IS the target.
c.onAction(e -> new PartDetailScreen(app, p.id).show());
return c;
}
/** Low stock is the one number on this card worth colouring, and the threshold is a rule. */
private static PalColor stockColor(int stock)
{
// secondary() is a TEXT TINT, not a colour — the old value made "low" a slightly darker grey
// than "fine", which is the same as not colouring it. Low stock is the one thing on the card
// somebody is scanning for.
return stock < 12 ? Tones.danger() : Styles.captionColor;
}
}package dev.cf.app.crud;
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.*;
/**
* The suppliers: the same listing shape over a different query.
*
* <p>This one exists to make a point that only shows up when there is more than one listing — the
* shape is <b>reusable across entities</b>, not a template for one. The card is different, the
* columns are different, the query is different, and none of the scrolling, mode-switching,
* searching or empty-state code is written twice.</p>
*
* <p>Note that {@code partCount} comes from the query, not from a field. Counting in SQL is one
* pass; counting in Java means loading every part to count them, which is fine at six suppliers
* and not fine at six thousand.</p>
*/
public class SuppliersTab extends ListingTab
{
private List<Repository.Supplier> rows;
/**
* @param app the shell
*/
public SuppliersTab(CrudApp app)
{
super(app);
}
protected String title()
{
return "Suppliers";
}
protected String searchHint()
{
return "Name or city…";
}
protected String tag()
{
return "suppliers";
}
protected int count()
{
return rows == null ? 0 : rows.size();
}
protected void columns(Listing l)
{
l.column("name", "Supplier", 16f)
.column("city", "City", 10f)
.column("parts", "Parts", 6f, Position.END)
.freeze(Position.START, "name");
}
protected Listing.Items load()
{
rows = Repository.get()
.suppliers(term);
return new Listing.Items()
{
public int count()
{
return rows.size();
}
public Component card(int i) throws Exception
{
Repository.Supplier s = rows.get(i);
Card c = new Card();
c.setLayout(new VerticalLayout(Position.START, Position.START));
c.add(LAY_AUTO, outgap(label(s.name, 1.2f, true, Styles.textColor, Position.START), 0,
0, 0, .2f));
c.add(LAY_AUTO, outgap(iconLine(0xE0C8, s.city), 0, 0, 0, .4f));
c.add(LAY_AUTO, label(s.partCount + (s.partCount == 1 ? " part" : " parts"), 1f, true,
Styles.captionColor, Position.START));
return c;
}
public void row(DataGrid.Row r, int i) throws Exception
{
Repository.Supplier s = rows.get(i);
r.cell(0, label(s.name, 1f, true, Styles.textColor, Position.START));
r.cell(1, label(s.city, 1f, false, Styles.captionColor, Position.START));
r.cell(2, label("" + s.partCount, 1f, false, Styles.textColor, Position.END));
}
};
}
}package dev.cf.app.crud;
import java.util.List;
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 static dev.crossfly.ui.Widgets.*;
/**
* The purchase orders: the listing with a lifecycle, and therefore the one with colour.
*
* <p>A status is not decoration. Four of them, in order, each with a tone the user can learn — and
* the tone comes from the theme, never from a hard-coded hex. That is what makes the same badge
* legible under Material, Fluent, iOS, light and dark without a single per-skin branch. Bake
* {@code 0xFF16A34A} in as "green" and it is unreadable on a dark surface the first time somebody
* flips the theme.</p>
*/
public class OrdersTab extends ListingTab
{
private List<Repository.Purchase> rows;
/**
* @param app the shell
*/
public OrdersTab(CrudApp app)
{
super(app);
}
protected String title()
{
return "Orders";
}
protected String searchHint()
{
return "Reference or supplier…";
}
protected String tag()
{
return "orders";
}
protected int count()
{
return rows == null ? 0 : rows.size();
}
protected void columns(Listing l)
{
l.column("ref", "Reference", 9f)
.column("supplier", "Supplier", 14f)
.column("placed", "Placed", 9f)
.column("status", "Status", 8f)
.column("total", "Total", 9f, Position.END)
.freeze(Position.START, "ref");
}
protected Listing.Items load()
{
rows = Repository.get()
.purchases(term);
return new Listing.Items()
{
public int count()
{
return rows.size();
}
public Component card(int i) throws Exception
{
Repository.Purchase p = rows.get(i);
Card c = new Card();
c.setLayout(new VerticalLayout(Position.START, Position.START));
Component head = new Component();
head.setLayout(new GridLayout(Position.START, Position.CENTER));
head.add(1f, 0f, label(p.ref, 1.2f, true, Styles.textColor, Position.START),
statusBadge(p.status));
c.add(LAY_AUTO, outgap(head, 0, 0, 0, .2f));
c.add(LAY_AUTO, outgap(label(p.supplierName + " · " + p.placedOn, 1f, false,
Styles.captionColor, Position.START), 0, 0, 0, .5f));
c.add(LAY_AUTO, label(Repository.money(p.totalCents), 1.2f, true, Styles.textColor,
Position.START));
return c;
}
public void row(DataGrid.Row r, int i) throws Exception
{
Repository.Purchase p = rows.get(i);
r.cell(0, label(p.ref, 1f, true, Styles.textColor, Position.START));
r.cell(1, label(p.supplierName, 1f, false, Styles.textColor, Position.START));
r.cell(2, label(p.placedOn, 1f, false, Styles.captionColor, Position.START));
r.cell(3, statusBadge(p.status));
r.cell(4, label(Repository.money(p.totalCents), 1f, false, Styles.textColor,
Position.END));
}
};
}
/**
* The status, as a badge whose tone comes from the theme.
*
* @param status one of {@link Repository#STATUS}
* @return the badge
*/
static Badge statusBadge(String status)
{
PalColor tone;
if ("received".equals(status))
tone = Styles.primaryColor;
else if ("shipped".equals(status))
tone = tertiary();
else if ("placed".equals(status))
tone = secondary();
else
tone = Styles.captionColor;
return toneBadge(tone, status);
}
}package dev.cf.app.crud;
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 outbox: the destination that makes "offline-first" something the user can see.
*
* <p>Most apps hide this, and then have to explain — in support, weeks later — where an edit went.
* An offline-first app has a real, nameable state ("saved here, not sent yet") and the user is
* entitled to know how many things are in it and to ask for a send. Two facts and one button is the
* whole screen; making it bigger would be inventing work.</p>
*
* <p>The send here is {@link Repository#flushOutbox()}, which just clears the queue. In a real app
* that call is the transport and can fail, which is why the button is disabled while it runs and
* why the count is re-read from the store afterwards rather than assumed to be zero.</p>
*/
public class OutboxTab extends Component
{
private final CrudApp app;
private boolean dirty = true;
private Label count, note;
private Button send;
/**
* @param app the shell
*/
public OutboxTab(CrudApp app)
{
this.app = app;
}
@Override
public void onLayout() throws Exception
{
if (dirty)
build();
super.onLayout();
}
/** Re-reads the pending count. Called when the tab is entered and after any write. */
public void reload()
{
if (count == null)
return;
int n = Repository.get()
.pendingCount();
count.setText("" + n);
note.setText(n == 0 ? "Everything you changed has been sent."
: n == 1 ? "One change is waiting to be sent."
: n + " changes are waiting to be sent.");
send.setEnabled(n > 0);
count.setValidLayout(false);
note.setValidLayout(false);
}
private void build() throws Exception
{
removeAll();
setLayout(new VerticalLayout(Position.CENTER, Position.CENTER));
getStyle().ingap(Gap.ofFont(1.5f));
add(LAY_AUTO, outgap(matglyph(0xE2C3, Styles.primaryColor, 3f), 0, 0, 0, .8f));
count = label("0", 3f, true, Styles.textColor, Position.CENTER);
count.testTag("outbox.count");
add(LAY_AUTO, count);
note = label("", 1f, false, Styles.captionColor, Position.CENTER);
note.testTag("outbox.note");
add(LAY_AUTO, outgap(note, 0, .2f, 0, 1.4f));
send = new Button("Send now");
send.testTag("outbox.send");
send.onAction(c ->
{
// Disable first, re-read after: a button that stays live during its own work invites the
// second tap that sends everything twice.
send.setEnabled(false);
int sent = Repository.get()
.flushOutbox();
app.refreshAll();
Hint.show(sent == 0 ? "Nothing to send" : sent + " sent", 2000);
});
add(LAY_AUTO, send);
add(LAY_AUTO, outgap(label("Edits are saved on this device the moment you make them, and"
+ " leave the queue when the server confirms them.", 1f, false, Styles.captionColor,
Position.CENTER), 0, 1.4f, 0, 0));
dirty = false;
reload();
}
}package dev.cf.app.crud;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;
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.*;
/**
* One part, in full — the overlay a card opens.
*
* <p><b>Why this is a Window and the tabs are not.</b> This is somewhere you <i>go</i>: it covers
* the shell, Back returns from it, and it has its own title. A destination you switch between is a
* component; a place you navigate into is a Window. Getting that backwards is what produces an app
* where the hardware Back key walks sideways through tabs.</p>
*
* <p>It re-reads the part from the store in {@code onAdd} rather than taking the object the card
* was holding. The card's copy was correct when the listing was built, which may have been before
* the edit the user just made. A screen that takes the caller's object shows stale data exactly
* once — right after an edit, which is the one moment the user is checking.</p>
*/
public class PartDetailScreen extends Window
{
private final CrudApp app;
private final long id;
private Repository.Part part;
/**
* @param app the shell, so a save can refresh it
* @param id the part to show
*/
public PartDetailScreen(CrudApp app, long id)
{
this.app = app;
this.id = id;
}
@Override
public void onAdd(boolean firstTime) throws Exception
{
// 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());
build();
}
private void build() throws Exception
{
removeAll();
setLayout(new VerticalLayout(Position.START, Position.START));
part = Repository.get()
.part(id);
if (part == null)
{
// The row was deleted while this screen was open, or from another device. Say so and
// leave — reopening a dead id would loop.
add(0f, new AppBar("Part", this::hide));
add(LAY_FILL, outgap(label("This part is no longer in your catalogue.", 1f, false,
Styles.captionColor, Position.CENTER), 1f, 2f, 1f, 0));
return;
}
add(0f, new AppBar(part.code, this::hide)
.action(0xE3C9, "detail.edit", this::edit) // edit
.action(0xE872, "detail.delete", this::confirmDelete)); // delete
Component body = new Component();
body.setLayout(new VerticalLayout(Position.START, Position.START));
body.setScrollable(ScrollDirection.VERTICAL);
body.getStyle()
.ingap(Gap.ofFont(1f, 1f, 1f, 1f));
body.testTag("detail.body");
add(LAY_FILL, body);
Label name = label(part.name, 2f, true, Styles.textColor, Position.START);
name.testTag("detail.name");
body.add(LAY_AUTO, outgap(name, 0, 0, 0, .3f));
if (part.dirty)
body.add(LAY_AUTO, outgap(toneBadge(secondary(), "not sent yet"), 0, 0, 0, .3f));
body.add(LAY_AUTO, outgap(label(part.category, 1f, false, Styles.captionColor,
Position.START), 0, 0, 0, 1f));
// A card of facts, not a paragraph of them: each is a labelled line the eye can find again.
Card facts = new Card();
facts.setLayout(new VerticalLayout(Position.START, Position.START));
facts.add(LAY_AUTO, factRow("Code", part.code));
facts.add(LAY_AUTO, factRow("Price", Repository.money(part.priceCents)));
facts.add(LAY_AUTO, factRow("In stock", "" + part.stock));
facts.add(LAY_AUTO, factRow("Supplier", Repository.get()
.supplierName(part.supplierId)));
facts.testTag("detail.facts");
body.add(LAY_AUTO, outgap(facts, 0, 0, 0, 1f));
if (part.notes != null && !part.notes.isEmpty())
{
body.add(LAY_AUTO, outgap(label("Notes", 1.2f, true, Styles.textColor, Position.START), 0,
0, 0, .3f));
body.add(LAY_AUTO, label(part.notes, 1f, false, Styles.textColor, Position.START));
}
}
private Component factRow(String name, String value) throws Exception
{
Component c = new Component();
c.setLayout(new GridLayout(Position.START, Position.CENTER));
c.getStyle()
.outgap(Gap.ofFont(0, 0, 0, .4f));
c.add(.5f, .5f, label(name, 1f, false, Styles.captionColor, Position.START),
label(value, 1f, true, Styles.textColor, Position.END));
return c;
}
private void edit()
{
new PartFormScreen(app, id, () ->
{
try
{
// Rebuild rather than patch: the form may have changed anything, and a screen that
// updates only the fields it thinks changed is a screen that eventually misses one.
build();
setValidLayout(false);
Screen.repaint();
}
catch (Exception e)
{
dev.crossfly.util.Debug.handle(e);
}
}).show();
}
private void confirmDelete()
{
// Destructive and not obviously reversible, so it asks. The question names the thing being
// deleted: "Delete this?" is a question the user cannot actually answer.
Alert a = new Alert("Delete " + part.code + "?",
"It will be removed here and the deletion queued for the server.", "Cancel", "Delete");
a.buttonAction(1, c ->
{
Repository.get()
.delete(id);
app.refreshAll();
hide();
Hint.show(part.code + " deleted", 2000);
});
a.show();
}
}package dev.cf.app.crud;
import java.util.List;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;
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 long form: create or edit a part.
*
* <p>A long form is its own architectural problem, and the three things that make one bearable are
* all visible here.</p>
*
* <h3>Sections, not a wall of fields</h3>
*
* <p>Eight fields in a row is a wall; eight fields under three headings is a form. The headings are
* how a user who came back to fix one thing finds it, and they cost three labels.</p>
*
* <h3>Validation says where, not just that</h3>
*
* <p>{@link #validate()} returns the first offending field, the screen scrolls it into view and
* puts the message under it. "Please fill in all required fields" is the error message of a form
* that does not know which field it means, and the user has to hunt.</p>
*
* <h3>The keyboard matches the field</h3>
*
* <p>{@code keyboard(Keyboard.NUMERIC)} on stock and price. It costs one call and removes an
* entire class of typo on a phone — and, unlike a regex on the way out, it prevents the mistake
* rather than complaining about it afterwards.</p>
*
* <p>Saving goes through {@link Repository#save(Repository.Part)}, which writes locally and queues.
* There is no spinner and no "saving…" state, because there is no request: on an offline-first app
* a save is a local write, and pretending it takes time in order to look busy is a lie the user
* will eventually catch when the network is out.</p>
*/
public class PartFormScreen extends Window
{
private final CrudApp app;
private final long id;
private final Runnable onSaved;
private TextField code, name, stock, price;
private TextArea notes;
private Dropdown category, supplier;
private Label error;
private Component body;
private List<Repository.Supplier> suppliers;
/**
* @param app the shell, so a save can refresh it
* @param id the part to edit, or 0 to create one
* @param onSaved runs after a successful save, before this screen hides
*/
public PartFormScreen(CrudApp app, long id, Runnable onSaved)
{
this.app = app;
this.id = id;
this.onSaved = onSaved;
}
@Override
public void onAdd(boolean firstTime) throws Exception
{
if (!firstTime)
return;
// 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));
Repository repo = Repository.get();
Repository.Part p = id == 0 ? new Repository.Part() : repo.part(id);
suppliers = repo.suppliers(null);
add(0f, new AppBar(id == 0 ? "New part" : "Edit part", this::hide)
.action(0xE876, "form.save", this::save)); // check
body = new Component();
body.setLayout(new VerticalLayout(Position.START, Position.START));
body.setScrollable(ScrollDirection.VERTICAL);
body.getStyle()
.ingap(Gap.ofFont(1f, 1f, 1f, 1f));
body.testTag("form.body");
add(LAY_FILL, body);
error = label("", 1f, false, secondary(), Position.START);
error.testTag("form.error");
error.setVisible(false);
body.add(LAY_AUTO, outgap(error, 0, 0, 0, .6f));
// ── Identity ──
section("Identity");
code = field("Code", p.code, "form.code");
name = field("Name", p.name, "form.name");
category = new Dropdown("Category", Repository.CATEGORY);
category.testTag("form.category");
if (p.category != null)
for (int i = 0; i < Repository.CATEGORY.length; i++)
if (Repository.CATEGORY[i].equals(p.category))
category.setSelectedIndex(i);
body.add(LAY_AUTO, outgap(category, 0, 0, 0, .6f));
// ── Commercial ──
section("Commercial");
price = field("Price", p.priceCents == 0 ? "" : Repository.money(p.priceCents), "form.price");
price.keyboard(Keyboard.NUMERIC);
stock = field("In stock", p.id == 0 ? "" : "" + p.stock, "form.stock");
stock.keyboard(Keyboard.NUMERIC);
String[] names = new String[suppliers.size()];
for (int i = 0; i < names.length; i++)
names[i] = suppliers.get(i).name;
supplier = new Dropdown("Supplier", names);
supplier.testTag("form.supplier");
for (int i = 0; i < names.length; i++)
if (suppliers.get(i).id == p.supplierId)
supplier.setSelectedIndex(i);
body.add(LAY_AUTO, outgap(supplier, 0, 0, 0, .6f));
// ── Notes ──
section("Notes");
notes = new TextArea(p.notes == null ? "" : p.notes);
notes.caption("Anything worth knowing about this part");
notes.testTag("form.notes");
notes.getStyle()
.autoHeight(Relative.ofFont(6f));
body.add(LAY_AUTO, outgap(notes, 0, 0, 0, 1f));
Button save = new Button(id == 0 ? "Create part" : "Save changes");
save.testTag("form.submit");
save.onAction(c -> save());
body.add(LAY_AUTO, save);
}
private void section(String title) throws Exception
{
body.add(LAY_AUTO, outgap(label(title.toUpperCase(), 1f, true, Styles.captionColor,
Position.START), 0, .6f, 0, .5f));
}
private TextField field(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, .6f));
return f;
}
/**
* Checks the form and reports the first problem.
*
* @return null when everything is in order, otherwise the message to show
*/
private String validate()
{
if (code.getText()
.trim()
.isEmpty())
return "A part needs a code.";
if (name.getText()
.trim()
.isEmpty())
return "A part needs a name.";
if (category.getSelectedIndex() < 0)
return "Pick a category.";
if (supplier.getSelectedIndex() < 0)
return "Pick a supplier.";
if (cents(price.getText()) <= 0)
return "Give the price as a number, for example 12.40.";
if (integer(stock.getText()) < 0)
return "Stock cannot be negative.";
return null;
}
private void save()
{
String problem = validate();
if (problem != null)
{
error.setText(problem);
error.setVisible(true);
error.setValidLayout(false);
Screen.repaint();
return;
}
Repository.Part p = id == 0 ? new Repository.Part() : Repository.get()
.part(id);
p.code = code.getText()
.trim();
p.name = name.getText()
.trim();
p.category = Repository.CATEGORY[category.getSelectedIndex()];
p.supplierId = suppliers.get(supplier.getSelectedIndex()).id;
p.priceCents = cents(price.getText());
p.stock = integer(stock.getText());
p.notes = notes.getText();
Repository.get()
.save(p);
app.refreshAll();
if (onSaved != null)
onSaved.run();
hide();
Hint.show("Saved on this device — queued to send", 2500);
}
/**
* Reads a typed amount as cents.
*
* <p>Parsing by hand rather than with a float, for the reason the repository gives: 12.40 is not
* representable in binary floating point, and a catalogue whose prices are each a hundredth of a
* cent off has totals that do not add up.</p>
*
* @param s what the user typed
* @return the amount in cents, or -1 when it is not a number
*/
private static long cents(String s)
{
if (s == null)
return -1;
String t = s.trim()
.replace(",", ".");
if (t.isEmpty())
return -1;
int dot = t.indexOf('.');
String whole = dot < 0 ? t : t.substring(0, dot);
String frac = dot < 0 ? "" : t.substring(dot + 1);
if (frac.length() > 2)
frac = frac.substring(0, 2);
while (frac.length() < 2)
frac += "0";
try
{
return Long.parseLong(whole.isEmpty() ? "0" : whole) * 100 + Long.parseLong(frac);
}
catch (NumberFormatException e)
{
return -1;
}
}
private static int integer(String s)
{
try
{
String t = s == null ? "" : s.trim();
return t.isEmpty() ? 0 : Integer.parseInt(t);
}
catch (NumberFormatException e)
{
return -1;
}
}
}package dev.cf.app.crud;
import dev.crossfly.ui.TextField;
/**
* A {@link TextField} that reports every edit, for the boxes that filter as the user types.
*
* <p>A plain TextField tells you its text when you ask; that is enough for a form, where the screen
* reads the field on save, and not enough for a search box, where the screen has to react to each
* keystroke. This adds the one missing thing — a callback — and nothing else, so every TextField
* feature (mask, caption, max length, styles) keeps working untouched.</p>
*
* <p><b>Why this class lives in the app and not in the framework.</b> It is the reference case for
* the rule in {@code AGENTS.md}: a class earns a place in CrossFly when it carries real weight that
* several apps would otherwise rebuild. A subclass that adds one field and one call does not — and
* a framework filled with one-line subclasses grows in size and in surface area for no gain. Copy
* this into your app; do not go looking for it in {@code dev.crossfly.ui}.</p>
*
* <p>The callback fires on <b>user</b> edits only — typing, pasting, backspace, delete. A
* programmatic {@code setText} does not fire it, because the caller already knows what it just
* wrote; without that rule a screen that clears its own box reloads twice.</p>
*
* <pre>
* SearchField search = new SearchField();
* search.caption("Code, name or city…");
* search.onTextChanged(() -> reload(search.getText()));
* </pre>
*/
public class SearchField extends TextField
{
private Runnable onTextChanged;
/**
* Sets the callback fired after each user edit.
*
* @param r the callback, or null to stop being notified
* @return this field, for chaining
*/
public SearchField onTextChanged(Runnable r)
{
onTextChanged = r;
return this;
}
/** Hooks the single point every keyboard edit funnels through. */
@Override
protected void afterTextChanged()
{
super.afterTextChanged();
if (onTextChanged != null)
onTextChanged.run();
}
/**
* The native keyboard path.
*
* <p>On Android and on the web the OS keyboard delivers text through the bridge, which
* synchronises the field with {@code replaceText} — the programmatic path, which by contract
* does not fire {@link #afterTextChanged()}. Notifying here keeps the "user edits only" rule
* intact; without it, filter-as-you-type is simply dead on those two platforms.</p>
*/
@Override
public void replaceText(String s, int cursorIni, int cursorEnd)
{
super.replaceText(s, cursorIni, cursorEnd);
if (onTextChanged != null)
onTextChanged.run();
}
}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;
}
}
}Versões desta página legíveis por máquina, para um modelo ler a prateleira em vez de adivinhar a API.