# Camera, barcode and GPS

> A recipe — one screen answering one question.

Ask whether the device HAS it before offering it. PlatformUtils.isSupported answers per capability, at runtime — never a check on which platform this is.

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

## What this shows

Camera, barcode and location — the three that can say no.

These differ from `R20Pickers` in one way that changes the whole screen: the hardware
may be missing, the permission may be refused, and the user may cancel. **Three outcomes, not
two**, and the one that gets forgotten is the first.

Forgetting it produces the worst version of this screen: a button that does nothing when
tapped, on a desktop with no camera, with no explanation. A person holding the device has no way
to tell that from a bug — so the availability check happens before the button exists, and an
unavailable capture says why instead of pretending.

The second half of the screen is the device APIs that are not pickers — vibration, speech
both ways, the battery, the network. They are here for the same reason: each is missing
somewhere, and on the web "somewhere" is decided by the browser the person opened rather than by
the build, so the same bundle shows a different set of enabled rows on a phone and on a laptop.
One row is the exception that proves the rule: **Lock to portrait** stays disabled on the web
although `setScreenRotation` really does attempt the lock there, because the browser keeps
one only in fullscreen — a page state, not device hardware, and therefore not something
`isSupported` could answer honestly at the moment an app asks.

Two more things worth knowing before you embed one of these. A camera or map picker is a
**native view layered on top of the GL canvas**: it does not scroll, does not clip to a
parent, and does not take part in a transition — which is why such a picker turns transitions
off, and why you open it rather than putting it in a page. And `BarcodePicker` is
abstract: you subclass it to say what a scan means.

## Framework types it uses

`BarcodePicker`, `Button`, `Capability`, `Card`, `Component`, `Device`, `Gap`, `GridLayout`, `Label`, `LocationPicker`, `MaterialIcon`, `PalColor`, `PhotoPicker`, `PlatformUtils`, `Screen`, `Styles`, `UIConstants`, `VerticalLayout`, `Widgets`

## Source

### dev/cf/recipe/R21Device.java

```java
package dev.cf.recipe;

import dev.cf.Tones;

import dev.crossfly.Capability;
import dev.crossfly.PlatformUtils;
import dev.crossfly.device.Device;
import dev.crossfly.ui.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.picker.*;
import dev.crossfly.ui.style.*;

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

/**
 * Camera, barcode and location — the three that can say no.
 *
 * <p>These differ from {@link R20Pickers} in one way that changes the whole screen: the hardware
 * may be missing, the permission may be refused, and the user may cancel. <b>Three outcomes, not
 * two</b>, and the one that gets forgotten is the first.</p>
 *
 * <p>Forgetting it produces the worst version of this screen: a button that does nothing when
 * tapped, on a desktop with no camera, with no explanation. A person holding the device has no way
 * to tell that from a bug — so the availability check happens before the button exists, and an
 * unavailable capture says why instead of pretending.</p>
 *
 * <p>The second half of the screen is the device APIs that are not pickers — vibration, speech
 * both ways, the battery, the network. They are here for the same reason: each is missing
 * somewhere, and on the web "somewhere" is decided by the browser the person opened rather than by
 * the build, so the same bundle shows a different set of enabled rows on a phone and on a laptop.
 * One row is the exception that proves the rule: <b>Lock to portrait</b> stays disabled on the web
 * although {@code setScreenRotation} really does attempt the lock there, because the browser keeps
 * one only in fullscreen — a page state, not device hardware, and therefore not something
 * {@code isSupported} could answer honestly at the moment an app asks.</p>
 *
 * <p>Two more things worth knowing before you embed one of these. A camera or map picker is a
 * <b>native view layered on top of the GL canvas</b>: it does not scroll, does not clip to a
 * parent, and does not take part in a transition — which is why such a picker turns transitions
 * off, and why you open it rather than putting it in a page. And {@link BarcodePicker} is
 * abstract: you subclass it to say what a scan means.</p>
 */
public class R21Device extends Recipe
{
   private Label answer;

   public String title()
   {
      return "Camera, barcode and GPS";
   }

   public String note()
   {
      return "Ask whether the device HAS it before offering it. PlatformUtils.isSupported answers"
         + " per capability, at runtime — never a check on which platform this is.";
   }

   public void build(Component body) throws Exception
   {
      answer = label("Nothing captured yet.", 1f, false, Styles.captionColor, Position.START);
      answer.testTag("device.answer");
      // Pinned below the scrolling body, so the answer stays in view (see Recipe.pin).
      pin(answer);

      row(body, 0xE8B6, "Scan a barcode", PlatformUtils.isSupported(Capability.BARCODE_SCAN),
         () -> new BarcodePicker("Point at a barcode")
      {
         protected void onBarcode(com.google.zxing.BarcodeFormat format, String code)
         {
            said("Barcode", code);
            hide();
         }
      }.show());

      row(body, 0xE412, "Take a photo", hasCamera(),
         () -> new PhotoPicker(PhotoPicker.Preview.PHOTO).show(a -> said("Photo", a)));

      row(body, 0xE55F, "Pick a location", hasLocation(),
         () -> new LocationPicker(latlon -> said("Location", latlon), 50.0, true, false, false)
            .show());

      // The device APIs that are not a picker. They belong on this screen for the same reason the
      // three above do: every one of them is missing somewhere, and the web is the target where
      // "somewhere" is decided by the browser the person happened to open, not by the build.
      row(body, 0xE62E, "Vibrate", PlatformUtils.isSupported(Capability.VIBRATION),
         () ->
         {
            Device.vibrate(300);
            said("Vibrate", "asked for 300 ms");
         });

      row(body, 0xE050, "Speak a sentence", PlatformUtils.isSupported(Capability.TEXT_TO_SPEECH),
         () ->
         {
            // The voice is the device's (it follows Locale.getDefault()), so the sentence has to be in
            // the device's language too: a Portuguese voice reading English sounds like letters
            // read one by one, which is what a person heard on a pt-BR Windows (13/10/2026).
            String sentence = sentenceInDeviceLanguage();
            Device.textToVoice(sentence);
            said("Speak", sentence);
         });

      row(body, 0xE029, "Dictate", PlatformUtils.isSupported(Capability.SPEECH_TO_TEXT),
         () -> Device.voiceToText(new Device.VoiceToTextListener()
      {
         public void onStart()
         {
            said("Dictate", "listening…");
         }

         public void onResult(String text)
         {
            said("Dictate", text);
         }

         public void onError(int code, String message)
         {
            // The browser's own error name comes through here: "not-allowed" is a refused
            // microphone and "no-speech" is silence, and a person needs to tell those apart.
            said("Dictate", "failed: " + message);
         }

         public void onFinish()
         {
         }
      }));

      row(body, 0xE1A4, "Read the battery", PlatformUtils.isSupported(Capability.BATTERY),
         () ->
         {
            int pct = PlatformUtils.instance.getRemainingBattery();
            // -1 here does not mean "no battery API" — the capability already answered that. It
            // means the browser has not resolved its promise yet, which is a real state in the
            // first moments after startup and worth saying rather than printing "-1%".
            said("Battery", pct < 0 ? "not known yet" : pct + "%");
         });

      row(body, 0xE1BA, "Network state", PlatformUtils.isSupported(Capability.WIRELESS_STATE),
         () -> said("Network", PlatformUtils.instance
            .radiodeviceGetState(UIConstants.WirelessType.WIFI)));

      // The odd one out, and deliberately so: setScreenRotation is implemented on the web and
      // SCREEN_ROTATION is NOT declared there, because the browser keeps an orientation lock only
      // while the document is fullscreen. So this row offers what the platform declares, and on
      // the web it is the row that stays disabled while the method behind it still exists.
      // One row that is both the lock and the way back: its name says what the next tap does. Two
      // rows ("Lock" and "Unlock") left the screen saying both at once (iPhone, 19/10/2026).
      Label[] rotationName = new Label[1];
      rotationName[0] = row(body, 0xE1C1, "Lock to portrait",
         PlatformUtils.isSupported(Capability.SCREEN_ROTATION), () ->
         {
            portraitLocked = !portraitLocked;
            Screen.setRotation(portraitLocked ? UIConstants.ScreenRotation.PORTRAIT
               : UIConstants.ScreenRotation.ANY);
            rotationName[0].setText(portraitLocked ? "Unlock rotation" : "Lock to portrait");
            rotationName[0].invalidateMeasure();
            said("Rotation", portraitLocked ? "locked to portrait" : "any orientation");
         });
   }

   private boolean portraitLocked;

   /**
    * Whether this platform can drive a camera.
    * <p>
    * This used to read {@code Device.platform == ANDROID || == IPHONE}, and that was wrong twice
    * over: the desktop has had a webcam backend all along, and the web build gets the browser's
    * camera through {@code getUserMedia} — so the recipe disabled two buttons that worked. Asking
    * the capability instead of the platform is the whole point of the screen, and now the screen
    * does it.
    */
   private static boolean hasCamera()
   {
      return PlatformUtils.isSupported(Capability.CAMERA);
   }

   /** Kept separate from the camera: a device may have one and not the other. */
   private static boolean hasLocation()
   {
      return PlatformUtils.isSupported(Capability.LOCATION);
   }

   /** Adds one capability card; returns its name label, for a row whose name changes. */
   private Label row(Component body, int icone, String name, boolean available, Runnable go)
      throws Exception
   {
      Card c = new Card();
      c.setLayout(new GridLayout(Position.START, Position.CENTER));
      c.getStyle()
         .outgap(Gap.ofFont(0, 0, 0, .5f));

      // The whole recipe is "ask before you offer", so whether the device HAS the thing is the
      // one fact on the card — and it was written in the same grey as everything else.
      PalColor tom = available ? Tones.accent() : Tones.danger();

      Component text = new Component();
      text.setLayout(new VerticalLayout(Position.START, Position.START));
      Label nameLabel = label(name, 1.2f, true, Styles.textColor, Position.START);
      text.add(LAY_AUTO, nameLabel);
      text.add(LAY_AUTO, label(available ? "Ready" : "Not available on this device", 1f, true,
         tom, Position.START));

      Button b = new Button(MaterialIcon.of(available ? 0xE5C8 : 0xE002, 1.4f));
      b.setEnabled(available);
      b.onAction(e -> go.run());
      // "device.take-a-photo": a test reaches each row by what it does, not by its place in the list.
      b.testTag("device." + name.toLowerCase(java.util.Locale.ROOT).replace(' ', '-'));

      c.add(0f, 1f, 0f, Tones.tile(icone, tom), outgap(text, .6f, 0, 0, 0), b);
      body.add(LAY_AUTO, c);
      return nameLabel;
   }

   /** The sentence "Speak" says, in the language the device's voice speaks. */
   private static String sentenceInDeviceLanguage()
   {
      switch (java.util.Locale.getDefault().getLanguage())
      {
         case "pt":
            return "O CrossFly roda em cinco plataformas.";
         case "es":
            return "CrossFly funciona en cinco plataformas.";
         case "de":
            return "CrossFly läuft auf fünf Plattformen.";
         case "fr":
            return "CrossFly fonctionne sur cinq plateformes.";
         default:
            return "CrossFly runs on five platforms.";
      }
   }

   /** One place for the answer, and it handles the cancel every one of them can return. */
   private void said(String what, Object a)
   {
      answer.setText(a == null ? what + ": cancelled" : what + ": " + a);
      // invalidateMeasure, not setValidLayout(false): the answer can grow from one line to two (an
      // error message does), and only the climb to the root gives the body room for the second line.
      // The bit alone re-laid the label inside the height it already had, and the second line was cut.
      answer.invalidateMeasure();
      Screen.repaint();
   }
}
```

## Shared scaffolding

Not part of this recipe, but read by it: the base class every recipe inherits its top bar and scrolling body from.

### dev/cf/recipe/Recipe.java

```java
package dev.cf.recipe;

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

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

/**
 * Base of a <b>recipe</b>: one screen that shows one thing, and nothing else.
 *
 * <p>A recipe is not a sample. A sample shows a component with all its knobs; a recipe answers a
 * question somebody actually has — "how do I put a filter sheet over a list?", "how do I make this
 * follow the theme?" — in as few lines as it takes, so the answer can be read in one screen and
 * copied without editing.</p>
 *
 * <p>That is the whole design constraint: <b>the boilerplate lives here, the substance lives in the
 * subclass.</b> A recipe that spends twenty lines building a title bar teaches the title bar.</p>
 *
 * <p>Write one as:</p>
 *
 * <pre>
 * public class MyRecipe extends Recipe
 * {
 *    public String title() { return "What this shows"; }
 *    public String note()  { return "The one sentence worth knowing."; }
 *
 *    public void build(Component body) throws Exception
 *    {
 *       body.add(LAY_AUTO, new Button("Tap"));
 *    }
 * }
 * </pre>
 */
public abstract class Recipe extends SampleWindow implements Described
{
   @Override
   public abstract String title();

   /**
    * The one thing a reader should take away — printed above the recipe, in caption tone.
    *
    * @return the note, or null for none
    */
   @Override
   public String note()
   {
      return null;
   }

   /**
    * Builds the recipe.
    *
    * @param body a vertical, scrollable container with sensible padding — add to it and stop
    * @throws Exception when building fails
    */
   public abstract void build(Component body) throws Exception;

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      addTopBar(title());

      // The page, not the surface. addTopBar just set fullscreenColor to the surface,
      // and on a white page a white card has no edges — see Tones.page(). Re-derived on restyle so
      // a dark-mode flip does not leave the light page behind.
      fullscreenColor = Tones.page();
      onRestyle(() -> fullscreenColor = Tones.page());

      contents.setLayout(new VerticalLayout(Position.START, Position.START));
      contents.getStyle()
         .ingap(Gap.ofFont(1f, 1f, 1f, 1f));

      String n = note();
      if (n != null)
         contents.add(LAY_AUTO, outgap(nota(n), 0, 0, 0, 1f));

      Component body = new Component();
      body.setLayout(new VerticalLayout(Position.START, Position.START));
      body.setScrollable(ScrollDirection.VERTICAL);
      body.testTag("recipe.body");
      contents.add(LAY_FILL, body);
      build(body);
      if (pinned != null)
      {
         // Measured again once its labels have sizes: the band's AUTO height is taken in the first
         // layout, before them, and on the iPhone it showed only its first line - the odometer's
         // "No text recognizer on this device." was set and never seen (17/10/2026).
         final Component band = pinned;
         onUI(band::invalidateMeasure);
      }
      if (pinned != null)
         body.add(LAY_AUTO, new Component()
         {
            @Override
            public int getAutoHeight()
            {
               // Room for the last row to scroll clear of the floating band.
               return pinned.getAutoHeight();
            }
         });
   }

   /**
    * The note, as a tinted band rather than three lines of grey.
    *
    * <p>It is the one sentence the reader is meant to leave with, and it used to be the least
    * legible thing on the screen: caption-coloured, caption-sized, indistinguishable from the
    * captions further down that are only labels. A tone and a glyph cost nothing here — nobody has
    * to read the band to know what it is.</p>
    *
    * @param n the note text
    * @return the band
    * @throws Exception when building fails
    */
   /**
    * Pins {@code c} to the bottom of the screen, in a band that FLOATS over the scrolling body: it
    * stays on screen however far the body is scrolled, and the body scrolls underneath it. That is the
    * place a recipe says what just happened. An answer at the top of the body went out of view as soon
    * as the person scrolled down to the button that produced it (14/10/2026).
    * <p>
    * The band is 80% OPAQUE (the surface at alpha 0xCC), as the user asked; it floats rather than
    * sitting below the body because a transparency with nothing behind it cannot be seen -- the first
    * version had the band under the body, and the person saw a plain bar. The body gets a spacer as
    * tall as the band at its end, so its last row can still scroll clear of it.
    *
    * @param c the component to pin, usually the answer label
    * @return {@code c}, to chain
    * @throws Exception if the layout refuses
    */
   protected <T extends Component> T pin(T c) throws Exception
   {
      Component band = new Component();
      band.setLayout(new VerticalLayout(Position.START, Position.START));
      band.style(Skin.get()
         .card()
         .backColor(Styles.backColor.withAlpha(0xCC))
         .borderColor(Styles.borderColor.withAlpha(0x33))
         .borderRadius(0)
         .shadow(0, 0, 0, 0)
         .ingap(Gap.ofFont(1f, 1f, 1f, 1.2f))
         .outgap(Gap.ofFont(0)));
      band.add(LAY_AUTO, c);
      // Floated: positioned at the bottom, full width, over whatever the body has there.
      band.setRelativeBounds(Relative.START, Relative.END, Relative.FULL, Relative.AUTO);
      contents.add(LAY_AUTO, band);
      pinned = band;
      return c;
   }

   /** The floating band {@link #pin} made, or null; the body ends in a spacer as tall as it. */
   private Component pinned;

   private Component nota(String n) throws Exception
   {
      Component band = new Component();
      band.setLayout(new GridLayout(Position.START, Position.START));
      band.style(Skin.get()
         .card()
         .backColor(Tones.accent().withAlpha(0x16))
         .borderColor(Tones.accent().withAlpha(0x4A))
         .shadow(0, 0, 0, 0)
         .outgap(Gap.ofFont(0)));
      // 0xE88E is the outlined "info". The glyph column hugs; the text takes the rest and wraps.
      // The weights are GridLayout's own — 0f hugs, 1f takes what is left. LAY_FILL belongs to the
      // vertical and horizontal layouts and means nothing here: it collapsed the text to nothing.
      band.add(LAY_AUTO, 1f, matglyph(0xE88E, Tones.accent(), 1.05f),
         outgap(label(n, 1f, false, secondary(), Position.START), .5f, 0, 0, 0));
      return band;
   }
}
```

### dev/cf/SampleWindow.java

```java
package dev.cf;

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

import static dev.cf.sample.CrossFlySamplesColors.*;

/**
 * Base class for all CrossFly sample windows.
 * <p>
 * Provides common infrastructure:
 * <ul>
 *   <li>Shared color palette and styles from {@link dev.cf.sample.CrossFlySamplesColors}</li>
 *   <li>{@link #addTopBar(String, Button...)} — standard deep-indigo top bar with back button</li>
 *   <li>{@link #barButton(String, Runnable)} — helper to create icon buttons for the bar</li>
 * </ul>
 *
 * <h3>Usage</h3>
 * <pre>
 * public class MySample extends SampleWindow {
 *     public void onAdd(boolean firstTime) throws Exception {
 *         if (!firstTime) return;
 *         initWindow();
 *         addTopBar("My Sample");
 *         // ... add content
 *     }
 * }
 * </pre>
 */
public abstract class SampleWindow extends Window
{
   /** The main content area of the window, added by {@link #addTopBar(String, Button...)}, for subclasses to add their content into. */
   protected Component contents;

   // ─── Top bar ─────────────────────────────────────────────────────────────

   /**
    * Builds and adds a standard top bar with a back (←) button on the left,
    * a centered title, and any optional extra buttons on the right.
    *
    * @param title        the text shown in the bar
    * @param rightButtons zero or more icon buttons placed aligned to the right
    * @throws Exception if component construction fails
    */
   protected void addTopBar(String title, Button... rightButtons) throws Exception
   {
      fullscreenColor = Styles.backColor.color();
      setLayout(new VerticalLayout());
      add(0f, buildTopBar(title, rightButtons));
      add(LAY_FILL, contents = new Component());
      contents.getStyle()
         .ingap(Gap.ofFont(0, .5f, 0, .5f));
   }

   /**
    * Same as {@link #addTopBar(String, Button...)}, but returns the bar component
    * instead of adding it, for callers that need to position it manually.
    *
    * @param title        the bar title
    * @param rightButtons optional right-side icon buttons
    * @return the assembled top-bar {@link Component}
    * @throws Exception if component construction fails
    */
   protected Component buildTopBar(String title, Button... rightButtons) throws Exception
   {
      Button btBack = barButton("\ue5c4", this::hide);   // arrow_back
      btBack.testTag("sample.back");
      // Padded on every side, like the bar's right buttons: with none above and below, the target
      // was the arrow itself, too small to hit on a small phone (iPhone 5s, 17/10/2026).
      btBack.getStyle()
         .ingap(Gap.ofFont(.75f))
         .outgap(Gap.ofFont(0, 0, .25f, 0));

      Component bar = new Component().style(barStyle);
      bar.setLayout(new GridLayout(Position.START, Position.CENTER));

      int n = 2 + rightButtons.length;
      Object[] params = new Object[n * 2];
      params[0] = 0f;
      params[1] = 1f;
      for (int i = 0; i < rightButtons.length; i++)
         params[2 + i] = 0f;
      params[n] = btBack;
      params[n + 1] = new Label(title).style(barTitleStyle);
      for (int i = 0; i < rightButtons.length; i++)
      {
         params[n + 2 + i] = rightButtons[i];
         rightButtons[i].getStyle()
            .ingap(Gap.ofFont(.75f));
      }

      bar.add(params);
      return bar;
   }

   /**
    * Creates a standard bar icon button (transparent, white icon, pressed feedback).
    *
    * @param materialIcon the Material Icon unicode codepoint string
    * @param action       the action to run when the button is tapped
    * @return the configured {@link Button}
    */
   protected Button barButton(String materialIcon, Runnable action)
   {
      return new Button(new MaterialIcon(materialIcon)).style(barButtonNormal)
         .pressedStyle(barButtonPressed)
         .onAction(e -> action.run());
   }
}
```
