{
  "id": "app-capture",
  "kind": "app",
  "tags": [
    "device"
  ],
  "title": "Fieldwork — capturing from the device",
  "note": "Every capture has three states, not two: empty, captured, and unavailable on this device — and the third is the one a phone-only developer never sees.",
  "className": "CaptureApp",
  "package": "dev.cf.app.capture",
  "doc": "<p><b>Fieldwork</b> — the field-capture shape: a checklist of device captures, every one of which\ncan fail.</p>\n\n<pre>\ngradlew lwjgl3:run --args=\"classname dev.cf.app.capture.CaptureApp\"\n</pre>\n\n<h2>What is actually hard about this shape</h2>\n\n<p>Nothing here is hard to <i>draw</i>. Four rows and a button is an afternoon. What is hard is\nthat each of the four is a request to hardware that can be refused, absent, or simply broken —\nand the interface has to stay honest about it while somebody stands in a yard holding a phone.\nThe whole example is organised around that.</p>\n\n<h3>1 · Every capture has three states, not two</h3>\n\n<p>Empty, captured, and <b>unavailable on this device</b>. The third is the one that gets\nforgotten, and forgetting it produces the worst version of this screen: a button that does\nnothing when tapped, on a device with no such hardware, with no explanation. Here\n<code>hasCamera()</code> and <code>hasLocation()</code> decide up front — by asking the capability, never\nby naming platforms — and an unavailable capture says why instead of pretending.</p>\n\n<h3>2 · The pickers are windows, and they own the camera</h3>\n\n<p><code>BarcodePicker</code>, <code>PhotoPicker</code> and <code>LocationPicker</code> are Windows layered over\na native view. They must be opened and closed, never embedded in a scrolling body: a native\ncamera surface sits on top of the GL canvas rather than inside it, so it does not scroll, does\nnot clip, and does not take part in a transition. That is also why a picker that hosts one turns\ntransitions off — an animated fade over a native view shows a black rectangle for its whole\nduration.</p>\n\n<h3>3 · Finishing states what is missing, and lets you finish anyway</h3>\n\n<p>Field work is not tidy: sometimes there is genuinely no signature because nobody was there.\nThe screen does not block on it — it records what is missing, so the debt is visible later\ninstead of being invented later. Blocking would only teach people to capture a scribble.</p>",
  "api": [
    {
      "name": "Alert",
      "doc": "/docs/ui/widgets/alert"
    },
    {
      "name": "BarcodePicker",
      "doc": "/docs/ui/widgets/barcodepicker"
    },
    {
      "name": "BottomSheet",
      "doc": null
    },
    {
      "name": "Button",
      "doc": "/docs/ui/widgets/button"
    },
    {
      "name": "Capability",
      "doc": null
    },
    {
      "name": "Card",
      "doc": null
    },
    {
      "name": "Component",
      "doc": null
    },
    {
      "name": "DrawingBoard",
      "doc": null
    },
    {
      "name": "Font",
      "doc": null
    },
    {
      "name": "Gap",
      "doc": null
    },
    {
      "name": "GridLayout",
      "doc": null
    },
    {
      "name": "Hint",
      "doc": "/docs/ui/widgets/hint"
    },
    {
      "name": "Label",
      "doc": "/docs/ui/widgets/label"
    },
    {
      "name": "LatLon",
      "doc": null
    },
    {
      "name": "LocationPicker",
      "doc": "/docs/ui/widgets/locationpicker"
    },
    {
      "name": "MaterialIcon",
      "doc": null
    },
    {
      "name": "PalColor",
      "doc": null
    },
    {
      "name": "PhotoPicker",
      "doc": "/docs/ui/widgets/photopicker"
    },
    {
      "name": "PlatformUtils",
      "doc": null
    },
    {
      "name": "Relative",
      "doc": null
    },
    {
      "name": "Screen",
      "doc": null
    },
    {
      "name": "Styles",
      "doc": null
    },
    {
      "name": "VerticalLayout",
      "doc": null
    },
    {
      "name": "Widgets",
      "doc": null
    },
    {
      "name": "Window",
      "doc": null
    }
  ],
  "files": [
    {
      "path": "dev/cf/app/capture/CaptureApp.java",
      "name": "CaptureApp.java",
      "code": "package dev.cf.app.capture;\n\nimport dev.cf.Described;\nimport dev.cf.Tones;\nimport dev.cf.app.AppBar;\nimport dev.cf.app.AppTheme;\n\nimport dev.crossfly.Capability;\nimport dev.crossfly.PlatformUtils;\nimport dev.crossfly.map.LatLon;\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.color.*;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.picker.*;\nimport dev.crossfly.ui.style.*;\n\nimport static dev.crossfly.ui.Widgets.*;\n\n/**\n * <b>Fieldwork</b> — the field-capture shape: a checklist of device captures, every one of which\n * can fail.\n *\n * <pre>\n * gradlew lwjgl3:run --args=\"classname dev.cf.app.capture.CaptureApp\"\n * </pre>\n *\n * <h2>What is actually hard about this shape</h2>\n *\n * <p>Nothing here is hard to <i>draw</i>. Four rows and a button is an afternoon. What is hard is\n * that each of the four is a request to hardware that can be refused, absent, or simply broken —\n * and the interface has to stay honest about it while somebody stands in a yard holding a phone.\n * The whole example is organised around that.</p>\n *\n * <h3>1 · Every capture has three states, not two</h3>\n *\n * <p>Empty, captured, and <b>unavailable on this device</b>. The third is the one that gets\n * forgotten, and forgetting it produces the worst version of this screen: a button that does\n * nothing when tapped, on a device with no such hardware, with no explanation. Here\n * {@link #hasCamera()} and {@link #hasLocation()} decide up front — by asking the capability, never\n * by naming platforms — and an unavailable capture says why instead of pretending.</p>\n *\n * <h3>2 · The pickers are windows, and they own the camera</h3>\n *\n * <p>{@link BarcodePicker}, {@link PhotoPicker} and {@link LocationPicker} are Windows layered over\n * a native view. They must be opened and closed, never embedded in a scrolling body: a native\n * camera surface sits on top of the GL canvas rather than inside it, so it does not scroll, does\n * not clip, and does not take part in a transition. That is also why a picker that hosts one turns\n * transitions off — an animated fade over a native view shows a black rectangle for its whole\n * duration.</p>\n *\n * <h3>3 · Finishing states what is missing, and lets you finish anyway</h3>\n *\n * <p>Field work is not tidy: sometimes there is genuinely no signature because nobody was there.\n * The screen does not block on it — it records what is missing, so the debt is visible later\n * instead of being invented later. Blocking would only teach people to capture a scribble.</p>\n */\npublic class CaptureApp extends Window implements Described\n{\n   /** The four captures, in the order the visit does them. */\n   private static final int ROW_LOCATION = 0, ROW_BARCODE = 1, ROW_PHOTO = 2, ROW_SIGNATURE = 3;\n\n   private static final String[] ROW_TITLE = { \"Location\", \"Asset code\", \"Photo\", \"Signature\" };\n   private static final String[] ROW_HINT = { \"Where the visit happened\", \"Scan the asset label\",\n      \"One photo of the installation\", \"Signed off by whoever received it\" };\n   private static final int[] ROW_ICON = { 0xE55F, 0xE905, 0xE412, 0xE745 };\n\n   /** What each row captured, or null while it is still empty. */\n   private final String[] captured = new String[4];\n\n   private final Component[] rows = new Component[4];\n   private final Label[] status = new Label[4];\n\n   private Label summary;\n   private LatLon where;\n\n   @Override\n   public String title()\n   {\n      return \"Fieldwork — capturing from the device\";\n   }\n\n   @Override\n   public String note()\n   {\n      return \"Every capture has three states, not two: empty, captured, and unavailable on this\"\n         + \" device — and the third is the one a phone-only developer never sees.\";\n   }\n\n   /** Hands the palette back to whatever was in force before this app opened. */\n   @Override\n   public void onRemove()\n   {\n      super.onRemove();\n      AppTheme.leave();\n   }\n\n   @Override\n   public void onAdd(boolean firstTime) throws Exception\n   {\n      if (!firstTime)\n         return;\n      // The brand FIRST: fullscreenColor below reads a token, and reading it before the palette\n      // is declared bakes in the skin's neutral grey for the life of the window.\n      AppTheme.enter();\n      // The PAGE, not the surface. Cards, buttons and fields are all drawn on the surface, so a\n      // page painted the same colour makes every one of their edges disappear. Read through\n      // onRestyle so a light/dark flip re-derives it instead of keeping the old mode's value.\n      fullscreenColor = AppTheme.pageColor();\n      onRestyle(() -> fullscreenColor = AppTheme.pageColor());\n      setLayout(new VerticalLayout(Position.START, Position.START));\n\n      add(0f, new AppBar(\"Visit 4812\", isRoot() ? null : this::hide));\n\n      Component body = new Component();\n      body.setLayout(new VerticalLayout(Position.START, Position.START));\n      body.setScrollable(ScrollDirection.VERTICAL);\n      body.getStyle()\n         .ingap(Gap.ofFont(1f, 1f, 1f, 1f));\n      body.testTag(\"capture.body\");\n      add(LAY_FILL, body);\n\n      body.add(LAY_AUTO, outgap(label(\"Northgate Industrial · Bay 3\", 1.5f, true, Styles.textColor,\n         Position.START), 0, 0, 0, .2f));\n      body.add(LAY_AUTO, outgap(label(\"Capture what you can. Anything missing is recorded as\"\n         + \" missing.\", 1f, false, Styles.captionColor, Position.START), 0, 0, 0, 1.2f));\n\n      for (int i = 0; i < rows.length; i++)\n      {\n         rows[i] = captureRow(i);\n         body.add(LAY_AUTO, outgap(rows[i], 0, 0, 0, .6f));\n      }\n\n      summary = label(\"\", 1f, false, Styles.captionColor, Position.START);\n      summary.testTag(\"capture.summary\");\n      body.add(LAY_AUTO, outgap(summary, 0, .8f, 0, .6f));\n\n      Button finish = Tones.action(\"Finish visit\");\n      finish.testTag(\"capture.finish\");\n      finish.onAction(c -> finish());\n      body.add(LAY_AUTO, finish);\n\n      updateSummary();\n   }\n\n   /**\n    * One capture row: glyph, title, current state, and the button that starts the capture.\n    *\n    * <p>Built once and updated in place rather than rebuilt — a capture is the one interaction here\n    * where the user is watching the exact row they just acted on, and rebuilding the list under\n    * them makes the screen flinch.</p>\n    */\n   private Component captureRow(final int i) throws Exception\n   {\n      Card card = new Card();\n      card.setLayout(new GridLayout(Position.START, Position.CENTER));\n      card.testTag(\"capture.row.\" + i);\n\n      Component text = new Component();\n      text.setLayout(new VerticalLayout(Position.START, Position.START));\n      text.add(LAY_AUTO, label(ROW_TITLE[i], 1.2f, true, Styles.textColor, Position.START));\n      status[i] = label(ROW_HINT[i], 1f, false, Styles.captionColor, Position.START);\n      status[i].testTag(\"capture.status.\" + i);\n      text.add(LAY_AUTO, status[i]);\n\n      String blocked = unavailable(i);\n      // The whole point of the screen is that \"unavailable\" is a THIRD state, so it gets a tone of\n      // its own. Written in the same grey as the hint it replaces, it was a fourth kind of caption.\n      PalColor tom = blocked == null ? Tones.accent() : Tones.danger();\n      Button go = new Button(MaterialIcon.of(blocked == null ? ROW_ICON[i] : 0xE002, 1.4f));\n      go.getStyle()\n         .iconColor(tom);\n      go.testTag(\"capture.go.\" + i);\n      go.setEnabled(blocked == null);\n      go.onAction(c -> capture(i));\n\n      if (blocked != null)\n      {\n         // Not a silent disable: a dead button with no explanation reads as a bug, and the person\n         // holding the phone has no way to tell the difference.\n         status[i].setText(blocked);\n         status[i].getStyle()\n            .textColor(Tones.danger())\n            .font(Font.get(1f, BOLD));\n         status[i].setValidLayout(false);\n      }\n\n      card.add(0f, 1f, 0f, Tones.tile(ROW_ICON[i], tom), outgap(text, .8f, 0, .8f, 0), go);\n      return card;\n   }\n\n   /**\n    * Why this capture cannot run here, or null when it can.\n    *\n    * @param i the row\n    * @return the message to show, or null\n    */\n   private String unavailable(int i)\n   {\n      if ((i == ROW_BARCODE || i == ROW_PHOTO) && !hasCamera())\n         return \"No camera on this device.\";\n      if (i == ROW_LOCATION && !hasLocation())\n         return \"No location service on this device.\";\n      return null;\n   }\n\n   /**\n    * Whether this device can drive a camera.\n    * <p>\n    * This read {@code Device.platform == ANDROID || == IPHONE} until the capability contract\n    * existed, and a list of platform names is wrong in every direction at once. It was wrong about\n    * what it excluded — the desktop has had a webcam backend all along, and the browser gets the\n    * camera through {@code getUserMedia}, so this screen dimmed two rows that worked. It was wrong\n    * about what it included — an iPad reports {@code IPAD}, not {@code IPHONE}, so the one device\n    * most likely to be held in a yard got the \"no camera\" message. And it goes stale on its own:\n    * adding a platform silently subtracts from every list like it, which is the failure mode that\n    * needs no commit to appear.\n    * <p>\n    * An example that models honest degradation cannot be the thing teaching the dishonest check.\n    * {@code R21Device} carries the same correction; this screen is where the pattern gets used in\n    * anger.\n    */\n   private static boolean hasCamera()\n   {\n      return PlatformUtils.isSupported(Capability.CAMERA);\n   }\n\n   /** Same question for GPS. Kept separate: a device may have one and not the other. */\n   private static boolean hasLocation()\n   {\n      return PlatformUtils.isSupported(Capability.LOCATION);\n   }\n\n   /** Starts one capture. Each branch opens a Window and comes back through a callback. */\n   private void capture(int i)\n   {\n      switch (i)\n      {\n         case ROW_LOCATION:\n            // minPdop 50, draggable pin, no high precision, no street view — the ordinary case.\n            new LocationPicker(latlon ->\n            {\n               where = latlon;\n               set(ROW_LOCATION, latlon == null ? null : latlon.toString());\n            }, 50.0, true, false, false).show();\n            break;\n\n         case ROW_BARCODE:\n            new BarcodePicker(\"Point at the asset label\")\n            {\n               protected void onBarcode(com.google.zxing.BarcodeFormat format, String code)\n               {\n                  set(ROW_BARCODE, code);\n                  hide();\n               }\n            }.show();\n            break;\n\n         case ROW_PHOTO:\n            new PhotoPicker(PhotoPicker.Preview.PHOTO)\n               .show(answer -> set(ROW_PHOTO, answer == null ? null : \"photo captured\"));\n            break;\n\n         case ROW_SIGNATURE:\n            new SignatureSheet(() -> set(ROW_SIGNATURE, \"signed\")).show();\n            break;\n\n         default:\n            break;\n      }\n   }\n\n   /** Records one capture and updates just that row. */\n   private void set(int i, String value)\n   {\n      captured[i] = value;\n      status[i].setText(captured[i] == null ? ROW_HINT[i] : captured[i]);\n      status[i].setValidLayout(false);\n      updateSummary();\n      Screen.repaint();\n   }\n\n   private void updateSummary()\n   {\n      int done = 0;\n      for (String s : captured)\n         if (s != null)\n            done++;\n      summary.setText(done + \" of \" + captured.length + \" captured\");\n      summary.setValidLayout(false);\n   }\n\n   /**\n    * Ends the visit, naming what is missing.\n    *\n    * <p>The message lists the gaps rather than counting them. \"2 items missing\" sends the user back\n    * to look; \"Missing: photo, signature\" tells them what to decide about.</p>\n    */\n   private void finish()\n   {\n      StringBuilder missing = new StringBuilder();\n      for (int i = 0; i < captured.length; i++)\n         if (captured[i] == null)\n            missing.append(missing.length() == 0 ? \"\" : \", \")\n               .append(ROW_TITLE[i].toLowerCase());\n\n      if (missing.length() == 0)\n      {\n         Hint.show(\"Visit closed — everything captured\", 2500);\n         return;\n      }\n      Alert a = new Alert(\"Finish with gaps?\", \"Missing: \" + missing + \".\\nThey will be recorded\"\n         + \" as missing, not as done.\", \"Go back\", \"Finish anyway\");\n      a.buttonAction(1, c -> Hint.show(\"Visit closed with \" + missing + \" missing\", 3000));\n      a.show();\n   }\n\n   /** Where the visit happened, once captured — kept so a later screen could plot it. */\n   public LatLon location()\n   {\n      return where;\n   }\n}\n"
    },
    {
      "path": "dev/cf/app/capture/SignatureSheet.java",
      "name": "SignatureSheet.java",
      "code": "package dev.cf.app.capture;\n\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.style.*;\n\nimport static dev.crossfly.ui.Widgets.*;\n\n/**\n * Capturing a signature: a {@link DrawingBoard} in a sheet, with a clear and a confirm.\n *\n * <p><b>Why a sheet and not a screen.</b> Signing is a step inside the visit, not a departure from\n * it. A sheet keeps the checklist visible behind it, so the person signing can see what they are\n * signing for, and dismissing it is a gesture rather than a navigation.</p>\n *\n * <p>Three details are what separate a signature pad that works in the field from one that does\n * not, and none of them is about drawing:</p>\n *\n * <ul>\n *   <li><b>The board gets a white background of its own</b>, not the theme's surface. A signature\n *       is captured to be printed and attached to a document; capturing white-on-navy because the\n *       user happened to have dark mode on produces an unusable image.</li>\n *   <li><b>Confirm is disabled until something is drawn.</b> An empty signature is the most common\n *       thing to capture by accident, and it is indistinguishable from a real one downstream.</li>\n *   <li><b>Clear asks nothing.</b> It is instantly and obviously reversible — you sign again — so a\n *       confirmation dialog here is friction with no payoff.</li>\n * </ul>\n */\npublic class SignatureSheet extends BottomSheet\n{\n   private final Runnable onSigned;\n\n   private DrawingBoard board;\n   private Button confirm;\n   private boolean drawn;\n\n   /**\n    * @param onSigned runs when the user confirms a non-empty signature\n    */\n   public SignatureSheet(Runnable onSigned)\n   {\n      super(\"SIGNATURE\", \"Sign below\");\n      this.onSigned = onSigned;\n      heightRatio(.7f);\n   }\n\n   @Override\n   public void onAdd(boolean firstTime) throws Exception\n   {\n      super.onAdd(firstTime);\n      if (!firstTime)\n         return;\n\n      Component b = body();\n      b.setLayout(new VerticalLayout(Position.START, Position.START));\n\n      // The pad: white, framed, and tall enough that a signature is not cramped into a strip.\n      Component pad = new Component();\n      pad.setLayout(new VerticalLayout(Position.START, Position.START));\n      pad.getStyle()\n         .backColor(0xFFFFFFFF)\n         .borderColor(Styles.borderColor)\n         .borderThick(Styles.borderThick)\n         .borderRadius(Styles.borderRadius)\n         .autoHeight(Relative.ofFont(11f));\n      pad.testTag(\"sign.pad\");\n\n      // Anonymous subclass rather than a listener: the DrawingBoard reports strokes by letting you\n      // override the touch handlers, which is how most CrossFly components are extended. There is\n      // no callback to look for, and adding one to the framework for this would be the wrong fix.\n      board = new DrawingBoard()\n      {\n         @Override\n         public void onTouchUp(float x, float y, int button) throws Exception\n         {\n            super.onTouchUp(x, y, button);\n            setDrawn(true);\n         }\n      };\n      board.setPenColor(0xFF111111);\n      pad.add(LAY_FILL, board);\n      b.add(LAY_AUTO, outgap(pad, 0, 0, 0, .6f));\n\n      b.add(LAY_AUTO, outgap(label(\"Sign with your finger or a stylus.\", 1f, false,\n         Styles.captionColor, Position.START), 0, 0, 0, .8f));\n\n      Component row = new Component();\n      row.setLayout(new GridLayout(Position.START, Position.CENTER));\n\n      Button clear = new Button(\"Clear\");\n      clear.testTag(\"sign.clear\");\n      clear.onAction(c ->\n      {\n         board.clear();\n         setDrawn(false);\n         Screen.repaint();\n      });\n\n      confirm = new Button(\"Confirm\");\n      confirm.testTag(\"sign.confirm\");\n      confirm.setEnabled(false);\n      confirm.onAction(c ->\n      {\n         onSigned.run();\n         hide();\n      });\n\n      row.add(.5f, .5f, clear, confirm);\n      b.add(LAY_AUTO, row);\n   }\n\n   private void setDrawn(boolean b)\n   {\n      if (drawn == b)\n         return;\n      drawn = b;\n      confirm.setEnabled(b);\n   }\n}\n"
    }
  ],
  "lines": 445,
  "sharedFiles": [
    {
      "path": "dev/cf/app/AppBar.java",
      "name": "AppBar.java",
      "code": "package dev.cf.app;\n\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.style.*;\n\nimport static dev.crossfly.ui.Widgets.*;\n\n/**\n * The top bar the four example apps share: back, title, and up to a couple of actions.\n *\n * <p>There is no top bar in CrossFly, and that is not an omission. A title bar is the single most\n * app-specific piece of chrome there is — height, colour, whether it collapses, whether the title\n * centres, what sits on the right — and a framework that decided those would be fought by every\n * app that used it. What the framework supplies is what a bar is <i>made of</i>: a container, a\n * layout, buttons, glyphs, and a style that follows the skin.</p>\n *\n * <p>So this is an <b>app-level</b> class, shared by the four examples here because they are four\n * examples in one repository — in a real project it would live in your own {@code ui} package. It\n * is about forty lines, which is roughly what a top bar costs, and it is worth reading as the\n * answer to \"how do I compose chrome out of primitives\".</p>\n *\n * <pre>\n * add(0f, new AppBar(\"Part\", this::hide).action(0xE3C9, this::edit));\n * </pre>\n */\npublic class AppBar extends Component\n{\n   private final String title;\n   private final Runnable onBack;\n\n   private final int[] icons = new int[3];\n   private final Runnable[] actions = new Runnable[3];\n   private final String[] tags = new String[3];\n   private int count;\n\n   /**\n    * @param title  the heading\n    * @param onBack what the back arrow does, or null for a bar with no back arrow\n    */\n   public AppBar(String title, Runnable onBack)\n   {\n      this.title = title;\n      this.onBack = onBack;\n   }\n\n   /**\n    * Adds a right-hand action. Up to three; past that the answer is a menu, not a fourth glyph.\n    *\n    * @param codepoint the Material Icons codepoint\n    * @param tag       the test tag for the button\n    * @param action    what it does\n    * @return this bar, for chaining\n    */\n   public AppBar action(int codepoint, String tag, Runnable action)\n   {\n      if (count < icons.length)\n      {\n         icons[count] = codepoint;\n         tags[count] = tag;\n         actions[count] = action;\n         count++;\n      }\n      return this;\n   }\n\n   @Override\n   public void onAdd(boolean firstTime) throws Exception\n   {\n      if (!firstTime)\n         return;\n      // A strip of surface with a hairline of elevation under it. Both colours are theme tokens, so\n      // the bar is right in dark mode and under every skin without a branch anywhere — and the\n      // shadow is expressed as a ratio of the font height, so it survives a high-DPI screen, where\n      // a shadow measured in pixels all but disappears.\n      getStyle().backColor(Styles.backColor)\n         .shadow(0, .06f, .25f, Styles.borderColor)\n         .ingap(Gap.ofFont(.5f, .4f, .5f, .4f));\n\n      setLayout(new GridLayout(Position.START, Position.CENTER));\n\n      // A bar with no back arrow gets no COLUMN for one. The obvious shortcut — always build the\n      // column and fill it with a bare `new Component()` — silently kills the entire row: an empty\n      // container has no layout, an auto-width column cannot measure it, and the bar comes out\n      // zero-high with the title inside it. It looks like the title was never added.\n      boolean hasBack = onBack != null;\n      int columns = (hasBack ? 1 : 0) + 1 + count;\n      Object[] cells = new Object[columns * 2];\n\n      int k = 0;\n      if (hasBack)\n         cells[k++] = 0f;                  // back: its own width\n      cells[k++] = 1f;                     // title: everything left over\n      for (int i = 0; i < count; i++)\n         cells[k++] = 0f;                  // actions: their own width\n\n      if (hasBack)\n      {\n         Button b = new Button(MaterialIcon.of(0xE5C4, 1.4f));   // arrow_back\n         b.testTag(\"bar.back\");\n         b.onAction(c -> onBack.run());\n         b.getStyle()\n            .outgap(Gap.ofFont(0, 0, .5f, 0));\n         cells[k++] = b;\n      }\n\n      Label t = label(title, 1.5f, true, Styles.textColor, Position.START);\n      t.testTag(\"bar.title\");\n      cells[k++] = t;\n\n      for (int i = 0; i < count; i++)\n      {\n         Button b = new Button(MaterialIcon.of(icons[i], 1.4f));\n         final Runnable a = actions[i];\n         b.testTag(tags[i]);\n         b.onAction(c -> a.run());\n         b.getStyle()\n            .outgap(Gap.ofFont(.4f, 0, 0, 0));\n         cells[k++] = b;\n      }\n\n      add(cells);\n   }\n}\n"
    },
    {
      "path": "dev/cf/app/AppTheme.java",
      "name": "AppTheme.java",
      "code": "package dev.cf.app;\n\nimport dev.crossfly.ui.color.ColorScheme;\nimport dev.crossfly.ui.style.Styles;\n\n/**\n * The one line that gives an app its colour, and the reason the four examples here have one.\n *\n * <p><b>An app declares its brand; it does not pick colours.</b> One seed goes in, and CrossFly\n * derives the whole scheme from it — primary, surface, on-surface, containers, the tonal fills\n * behind an active tab, the ring of a gauge, both themes. Nothing downstream names a colour, which\n * is what makes dark mode and the skin picker work without a single branch.</p>\n *\n * <p>Skip this and the app is not \"unstyled\", it is <b>grey</b>: the skin's neutral palette is what\n * you get when nobody declared anything, and it looks exactly like a bug. That is what happened to\n * these four — they were correct from the samples menu, which declares a brand of its own, and\n * monochrome when launched straight from {@code classname}, which is how anybody copying them would\n * run them.</p>\n *\n * <h3>Why enter/leave and not just a call at startup</h3>\n *\n * <p>The brand is global state. A real app declares it once in {@code Main} and never thinks about\n * it again — and if that is your case, {@code Styles.setBrand(BRAND)} at startup is the whole story\n * and this class is more machinery than you need.</p>\n *\n * <p>These four are different: they are guests. They open from a menu that has its own brand, and\n * from a test suite whose later screenshots are compared against committed images. A guest that\n * changes global state and walks away leaves its host repainted in someone else's colour — the\n * samples menu comes back blue, the goldens fail three tests later, and neither failure points at\n * the app that caused it. So each app calls {@link #enter()} when it opens and {@link #leave()}\n * when it closes, and the palette that was in force is handed back.</p>\n */\npublic final class AppTheme\n{\n   /**\n    * The seed every colour in the four example apps is derived from.\n    *\n    * <p>Deep blue, chosen against orange for one reason: orange is the brand of the production app\n    * these examples were extracted from, and an example that wears it invites the reader to think\n    * the two are related. For orange instead, this is the only line to change —\n    * {@code 0xFFE2622C} is a good one. Nothing else in the four apps names a colour.</p>\n    */\n   public static final int BRAND = 0xFF1565C0;\n\n   /**\n    * The page behind the cards: the surface, pulled a little way towards the brand.\n    *\n    * <p><b>Why a page needs its own colour at all.</b> Cards, buttons and fields are all drawn on\n    * the skin's surface, which in a light theme is white or near it. Put them on a white page and\n    * every edge disappears — the card stops reading as a card and a white button on white looks\n    * like a mistake rather than a control. What separates them is not a border, it is the page\n    * being a shade the foreground is not.</p>\n    *\n    * <p><b>Why it is derived and not a hex.</b> {@code 0xFFF2F5F9} would be a perfectly nice light\n    * blue and would be wrong twice: it stops being light blue the moment somebody changes\n    * {@link #BRAND} to orange, and it stays light in dark mode, where the page must go darker than\n    * the cards rather than lighter. Mixing the primary into the surface at 8% follows both — the\n    * tint always matches the brand, and in a dark theme the same call yields a deep, slightly\n    * tinted ground.</p>\n    *\n    * <p>Read it through {@code onRestyle} rather than once, so a theme flip re-derives it.</p>\n    *\n    * @return the ARGB page colour for the palette currently in force\n    */\n   public static int pageColor()\n   {\n      return dev.cf.Tones.page();\n   }\n\n   /** What was in force before the outermost {@link #enter()}. */\n   private static Integer previousBrand;\n   private static ColorScheme previousScheme;\n\n   /** How many example apps are open. Restores only when the last one closes. */\n   private static int depth;\n\n   private AppTheme()\n   {\n   }\n\n   /**\n    * Declares {@link #BRAND}, remembering whatever was in force.\n    *\n    * <p>Call it first thing in {@code onAdd}. It triggers a restyle, so every window already open\n    * re-resolves its colours — including this one, which is why it can run before the screen is\n    * built.</p>\n    */\n   public static void enter()\n   {\n      if (depth++ == 0)\n      {\n         previousBrand = Styles.hasBrand() ? Integer.valueOf(Styles.brand()) : null;\n         ColorScheme live = Styles.colorScheme();\n         if (live != null)\n         {\n            // A SNAPSHOT, never the reference. Styles keeps ONE ColorScheme instance for the life\n            // of the app and mutates its roles in place on every seed change — deliberately, so\n            // that a PalColor captured into a style at build time keeps resolving and follows the\n            // theme forever. The consequence for anybody saving and restoring: the object you kept\n            // IS the object about to be overwritten, so handing it back restores nothing at all.\n            // This cost four broken golden images in a suite that had been green all morning.\n            previousScheme = ColorScheme.fromSeed(BRAND, live.isDark);\n            previousScheme.copyFrom(live);\n         }\n      }\n      Styles.setBrand(BRAND);\n   }\n\n   /**\n    * Hands the palette back to the host.\n    *\n    * <p>Call it from {@code onRemove}, which CrossFly runs on {@code hide()}. Two cases: the host\n    * had declared a brand, so re-declaring it regenerates everything; or nothing was declared, so\n    * the declaration is dropped <b>and</b> the scheme that was actually in force is restored —\n    * clearing the brand alone would leave this app's blue behind, still painted, just no longer\n    * owned by anybody.</p>\n    */\n   public static void leave()\n   {\n      if (depth > 0 && --depth == 0)\n      {\n         if (previousBrand != null)\n            Styles.setBrand(previousBrand.intValue());\n         else\n         {\n            Styles.clearBrand();\n            if (previousScheme != null)\n               Styles.setColorScheme(previousScheme);\n         }\n         previousBrand = null;\n         previousScheme = null;\n      }\n   }\n}\n"
    }
  ],
  "page": "https://crossfly.dev/examples/app-capture",
  "run": "https://crossfly.dev/playground/index.html?ex=app-capture",
  "runLocally": "gradlew lwjgl3:run --args=\"classname dev.cf.app.capture.CaptureApp\"",
  "license": "See crossfly.dev for licensing. The code is published to be copied into your own project."
}