# Aptean Mesh -- Agent Skill

Use this skill when building Business Central AL extensions for the Aptean Mesh mobile WMS platform. Aptean Mesh uses Server-Driven UI: your AL code serves JSON page definitions, and the mobile client renders them.

## Dependency

Add to your `app.json`:

```json
{
  "id": "9af7715b-c3c3-44ad-9a3d-b953eab5f6c5",
  "name": "Aptean Mesh",
  "publisher": "Aptean",
  "version": "2601.0.0.0"
}
```

Object names use the `220FDW` suffix (Aptean's object range). Your own objects should use your own range.

## Architecture

```
Mobile Client  <--JSON-RPC-->  BCRPC220FDW API Page
                                    |
                               MobileRPCRegistry220FDW
                                    |  (OnResolveService event)
                                    v
                               Your Codeunit (implements IBCRPC220FDW)
                                    |
                                    v
                               Returns JSON page data
```

## Core Pattern: Implement a Service

```al
codeunit 50100 MyService implements IBCRPC220FDW
{
    [EventSubscriber(ObjectType::Codeunit, Codeunit::MobileRPCRegistry220FDW, OnResolveService, '', false, false)]
    local procedure ResolveMyService(CodeunitId: Integer; var Service: Interface IBCRPC220FDW; var IsServiceResolved: Boolean)
    begin
        if IsServiceResolved then exit;
        if CodeunitId = Codeunit::MyService then begin
            Service := this;
            IsServiceResolved := true;
        end;
    end;

    procedure RPC(var State: Codeunit MobileRPCState220FDW) ResponseJson: JsonObject
    begin
        case State.Method() of
            'Initialize': exit(Initialize(State));
            'OnScan':     exit(OnScan(State));
            'OnAction':   exit(OnAction(State));
        end;
    end;

    local procedure Initialize(var State: Codeunit MobileRPCState220FDW) Result: JsonObject
    var
        JsonHelper: Codeunit JsonHelper220FDW;
    begin
        JsonHelper.WriteText(Result, 'title', 'My Page');
    end;

    local procedure OnScan(var State: Codeunit MobileRPCState220FDW) Result: JsonObject
    begin
        // State.GetScannedValue('') = raw barcode; use GS1 AI codes to extract fields
        State.SetDocumentNo(State.GetScannedValue(''));
        Result := State.Serialize();
    end;

    local procedure OnAction(var State: Codeunit MobileRPCState220FDW) Result: JsonObject
    begin
        State.GetInput('fieldName', false);
        Result := State.Serialize();
    end;
}
```

After creating the codeunit, register it in the **Mobile Service Registry** table:
- App Bundle ID = your bundle
- Mobile Service Code = the code referenced in your JSON page `service` field
- Codeunit Id = your codeunit's object ID

### Contract Auto-Triggers

Two actions in the page contract fire automatically without user interaction:

| Contract Field | When it fires |
|----------------|---------------|
| `initialAction` | Immediately when the page loads — use it to fetch and bind data |
| `scanAction` | Every time the user scans a barcode — receives the scan via `State.GetScannedValue()` |

You do not need a button to call `Initialize`. If your page has `"initialAction": "init"`, the client calls it on load.

## JSON Page Structure

```json
{
  "pageId": "MY-PAGE",
  "service": "MY-SERVICE",
  "header": {
    "title": "Page Title",
    "titleKey": "boundTitleField",
    "subtitle": "Optional subtitle",
    "refresh": true,
    "drawer": [
      { "title": "Menu Item", "actionId": "navAction" }
    ]
  },
  "contract": {
    "initialAction": "init",
    "scanAction": "scan",
    "actions": [
      { "id": "init",      "type": "api",      "method": "Initialize" },
      { "id": "scan",      "type": "api",      "method": "OnScan" },
      { "id": "post",      "type": "api",      "method": "OnPost",
        "confirm": { "title": "Post Receipt?", "message": "This will post {documentNo}." } },
      { "id": "enterQty",  "type": "api",      "method": "OnEnterQty",
        "inputPrompt": { "label": "Enter quantity", "inputType": "NUMERIC" } },
      { "id": "navAction", "type": "navigate", "destination": "OTHER-PAGE",
        "data": { "docNo": "{documentNo}" } }
    ],
    "filters": [
      {
        "id": "direction",
        "label": "#Direction",
        "mode": "SEGMENT",
        "options": [
          { "label": "#Inbound",  "value": "INBOUND"  },
          { "label": "#Internal", "value": "INTERNAL" },
          { "label": "#Outbound", "value": "OUTBOUND" }
        ]
      }
    ]
  },
  "body": [
    { "type": "text",     "valueKey": "title",    "style": "HEADER" },
    { "type": "card",     "titleKey": "cardTitle", "fields": [
      { "label": "Field 1", "valueKey": "field1" },
      { "label": "Field 2", "valueKey": "field2" }
    ]},
    { "type": "repeater", "dataKey": "items", "key": "documentNo",
      "template": "CARD", "fields": [
        { "label": "#Type",   "valueKey": "type"   },
        { "label": "#Doc No", "valueKey": "documentNo" }
      ],
      "trailingAction": { "icon": "delete", "action": "onDelete", "tone": "ERROR" }
    }
  ],
  "footer": {
    "actions": [
      { "label": "#Post", "action": "post", "tone": "SUCCESS" }
    ]
  }
}
```

### Data Binding

Components use `valueKey` to bind to keys in the service response JSON. The service returns:
```json
{ "title": "Hello", "field1": "Value 1", "items": [{"documentNo": "PO-001", "type": "RECEIPT"}] }
```
The component with `"valueKey": "title"` displays "Hello". Repeaters bind to arrays via `dataKey`.

### Component Types

| Type | JSON `type` | Key Properties |
|------|-------------|----------------|
| Text | `"text"` | `valueKey`, `style` (HEADER/BODY/CAPTION), `tone` |
| Button | `"button"` | `label`, `action`, `variant` (FILLED/TONAL/OUTLINED), `tone` |
| Input | `"input"` | `inputId`, `label`, `valueKey`, `action` |
| Card | `"card"` | `titleKey`, `fields` (array of {label, valueKey}), `onTapAction` |
| Repeater | `"repeater"` | `dataKey`, `key`, `template` (CARD/GRID), `fields`, `trailingAction` |
| InfoPane | `"infoPane"` | `titleKey`, `subtitleKey`, `image`, `fields` |
| SidePane | `"sidePane"` | `dataKey`, `labelKey`, `onSelectAction` |
| Selector | `"selector"` | `selectorType` (SEGMENT/DROPDOWN), `options`, `onSelectAction` |
| ProgressBar | `"progressBar"` | `valueKey`, `maxKey`, `format` (PERCENT/FRACTION) |
| TreeMap | `"TREE_MAP"` | `dataKey`, `labelKey`, `valueKey` |
| StepInput | `"stepInput"` | `action` (triggers workflow step) |

All components support `visibleKey` and `enabledKey` — set them to boolean values in the response:

```al
// In AL: control visibility from the response
JsonHelper.WriteBoolean(Result, 'canPost', HasRequiredData);
JsonHelper.WriteBoolean(Result, 'lotRequired', Item."Lot Nos." <> '');
```
```json
{ "type": "button", "label": "#Post", "action": "post", "visibleKey": "canPost" }
{ "type": "input",  "inputId": "lot", "label": "#Lot", "visibleKey": "lotRequired" }
```

### Action Types

| Type | JSON `type` | Purpose | Key Properties |
|------|-------------|---------|----------------|
| API | `"api"` | Call AL service method | `method`, `params`, `confirm`, `inputPrompt` |
| Navigate | `"navigate"` | Go to another page | `destination`, `data` (context params) |
| Filter | `"filter"` | Execute filter lookup | `method`, `params` |
| Lookup | `"lookup"` | Modal selection | `method`, `params` |

Use `{FieldName}` in `data` values for parameter interpolation from the current response.

**`confirm`** — shows a confirmation dialog before calling AL. Supports `{fieldValue}` interpolation in the message:
```json
{ "id": "delete", "type": "api", "method": "OnDelete",
  "confirm": { "title": "Delete entry?", "message": "Remove scan entry for {itemNo}?" } }
```

**`inputPrompt`** — shows an inline input box before calling AL. `inputType`: `TEXT`, `NUMERIC`, `BARCODE`:
```json
{ "id": "adjust", "type": "api", "method": "OnAdjust",
  "inputPrompt": { "label": "Adjustment quantity", "inputType": "NUMERIC" } }
```

### Filter Modes

| `mode` | Renders as | Use case |
|--------|-----------|----------|
| `SEGMENT` | Tab strip | 2–4 mutually exclusive categories (Inbound/Internal/Outbound) |
| `DROPDOWN` | Drop-down picker | Long option lists |
| `LOOKUP` | Modal search | Server-side dynamic option loading |
| `DATE` | Date picker | Date range filtering |

Static options are declared directly in the filter definition. Dynamic options are loaded by a `filter`-type action whose `method` returns an array.

### Adding Custom Filter Options

Subscribe to `MobileOptionsProvider220FDW.OnRegisterFilterOptions` to inject options into an existing filter type:

```al
[EventSubscriber(ObjectType::Codeunit, Codeunit::MobileOptionsProvider220FDW,
    OnRegisterFilterOptions, '', false, false)]
local procedure AddTransferOrderOption(FilterType: Code[20]; var Options: JsonArray)
var
    Provider: Codeunit MobileOptionsProvider220FDW;
    Opt: JsonObject;
begin
    if FilterType <> Provider.FilterTypeDocType() then exit;

    Opt.Add('label', TransferOrderLbl);        // translatable display label
    Opt.Add('value', TransferOrderTok);        // stable locked token
    Options.Add(Opt);
end;

var
    TransferOrderLbl: Label 'Transfer Order';
    TransferOrderTok: Label 'TransferOrder', Locked = true;
```

Always use the provider's accessor methods (`FilterTypeDocType()`, etc.) to identify filter types — never hardcode the token string.

### Command Types (server-to-client, returned via State.Command())

| Command | Builder Method | Purpose |
|---------|---------------|---------|
| Alert | `State.Command().Alert(title, message)` | Show alert dialog |
| Confirm | `State.Command().Confirm(title, message, action)` | Confirmation dialog |
| Navigate | `State.Command().Navigate(pageCode)` | Navigate to page |
| NavBack | `State.Command().NavBack()` | Go back |
| NavBackTo | `State.Command().NavBackTo(pageCode)` | Back to specific page |
| Vibrate | `State.Command().VibrateSuccess()` / `.VibrateError()` | Haptic feedback |
| Refresh | `State.Command().RefreshScreen()` | Refresh current page |

### Tone Values

`LOW`, `MEDIUM`, `HIGH`, `ERROR`, `SUCCESS`, `WARNING`, `INFO` -- controls component color styling.

### Repeater Key Rules

The `key` field on a repeater is the property name within each array item that uniquely identifies that row. The client uses it for Jetpack Compose `LazyColumn` diffing. **Always declare it.**

```json
{ "type": "repeater", "dataKey": "lines", "key": "lineNo", "template": "CARD", "fields": [...] }
```

**Never use `CreateGuid()` as a key value.** A new GUID on every call means Compose sees every row as brand new — full list rebuild, visible flicker, lost scroll position.

```al
// ✅ Correct — stable natural business key
LineObj.Add('lineNo', Format(WhseReceiptLine."Line No."));

// ❌ Wrong — new GUID every call, forces full re-render
LineObj.Add('id', Format(CreateGuid()));

// ❌ Wrong — index shifts when filtered or sorted
LineObj.Add('index', Counter);
```

Use a natural business key: `DocumentNo + '|' + Format(LineNo)`, `Item."No."`, etc.

### Repeater Trailing Action (Swipe-to-Reveal)

`trailingAction` adds a swipe-to-reveal action on each row:

```json
{ "type": "repeater", "dataKey": "entries", "key": "entryNo",
  "trailingAction": { "icon": "delete", "action": "onDeleteEntry", "tone": "ERROR" } }
```

The action receives the row's data via `params` — read the key in AL with `State.GetInput('entryNo', true)`.

## Error Handling

Two patterns — choose based on whether the error should terminate the call or be shown as a soft message:

```al
// Pattern 1: Hard error — BC exception, client shows a system error dialog
Error(ItemNotFoundErr, ScannedBarcode);

// Pattern 2: Soft alert — controlled message with custom title shown as an overlay
// Use this for business validation where you want a friendly UX
State.Command()
    .Alert('Lot Expired', StrSubstNo('Lot %1 expired on %2.', LotNo, Format(ExpiryDate)))
    .VibrateError();
Result := State.Serialize();
exit(Result);

// Pattern 3: Confirm before proceeding — client shows dialog, re-calls method on OK
State.Command().Confirm('Overstock', 'Quantity exceeds outstanding. Post anyway?', 'onConfirmPost');
Result := State.Serialize();
exit(Result);
```

Use `Error()` for programming errors and unrecoverable BC exceptions. Use `State.Command().Alert()` for business validation messages where you want a polished user experience.

## Barcode Scanning -- GS1 AI Codes

`State.GetScannedValue(AICode)` extracts a specific field from a GS1 composite barcode. `State.RawBarcode()` always returns the complete raw string.

| AI Code | Field | Example |
|---------|-------|---------|
| `''` | Raw full barcode (no parsing) | `'0107622200123456101234ABCD'` |
| `'01'` | GTIN / item number | `'07622200123456'` |
| `'10'` | Lot / batch number | `'1234ABCD'` |
| `'17'` | Expiry date (YYMMDD) | `'261231'` |
| `'21'` | Serial number | `'SN-0042'` |

```al
ItemNo  := State.GetScannedValue('01');   // extract GTIN from composite barcode
LotNo   := State.GetScannedValue('10');   // extract lot number
RawCode := State.RawBarcode();            // always the full composite barcode
```

If the scanner sends a simple non-GS1 barcode, `GetScannedValue('')` and `RawBarcode()` return the same value.

## Workflow Pattern: Step Handler

For guided multi-step workflows (scan item → enter quantity → confirm):

```al
codeunit 50101 MyWorkflowHandler implements IStepHandler220FDW
{
    [EventSubscriber(ObjectType::Codeunit, Codeunit::WorkflowHandlerRegistry220FDW, OnResolveWorkflowHandler, '', false, false)]
    local procedure ResolveHandler(CodeunitId: Integer; var StepHandler: Interface IStepHandler220FDW; var IsHandlerResolved: Boolean)
    begin
        if IsHandlerResolved then exit;
        if CodeunitId = Codeunit::MyWorkflowHandler then begin
            StepHandler := this;
            IsHandlerResolved := true;
        end;
    end;

    procedure IsStepRequired(var State: Codeunit MobileRPCState220FDW; var ItemMgt: Codeunit MobileItemMgt220FDW; WorkflowStep: Record "Mobile Workflow Step 220FDW"): Boolean
    begin
        exit(true);
    end;

    procedure HandleStep(var State: Codeunit MobileRPCState220FDW; var ItemMgt: Codeunit MobileItemMgt220FDW; WorkflowStep: Record "Mobile Workflow Step 220FDW"): Boolean
    var
        StepInput: Codeunit MobileWorkflowBuilder220FDW;
    begin
        case WorkflowStep."Step Type" of
            WorkflowStep."Step Type"::Item:
            begin
                if State.GetScannedValue('') = '' then begin
                    StepInput.Configure(WorkflowStep, 'Scan an item barcode');
                    State.SetScanInput(StepInput.Build());
                    exit(false);
                end;
                ItemMgt.Resolve(State.GetScannedValue(''));
                exit(true);
            end;
            WorkflowStep."Step Type"::Quantity:
            begin
                if State.GetLastEnteredQty() = 0 then begin
                    StepInput.Configure(WorkflowStep, 'Enter quantity');
                    State.SetScanInput(StepInput.Build());
                    exit(false);
                end;
                exit(true);
            end;
        end;
    end;

    procedure UndoStep(var State: Codeunit MobileRPCState220FDW; var ItemMgt: Codeunit MobileItemMgt220FDW; WorkflowStep: Record "Mobile Workflow Step 220FDW"): Boolean
    begin
        case WorkflowStep."Step Type" of
            WorkflowStep."Step Type"::Item:    State.SetItemNo('', false);
            WorkflowStep."Step Type"::Quantity: State.SetQuantity(0, false);
        end;
        exit(true);
    end;
}
```

Configure workflow in BC tables:
- **Mobile Workflow**: set Resolver Codeunit ID to your handler
- **Mobile Workflow Step**: define steps with Step Type (Item, Quantity, Bin, Lot, etc.) and Step Input Type (Barcode, Numeric, Choice, etc.)

### StepType Enum Values (Extensible)

`Init`(0), `Bin`(10), `Item`(20), `LicensePlate`(30), `LotNumber`(40), `ExpirationDate`(50), `Quantity`(60), `SerialNumber`(70), `Location`(80), `Variant`(90), `ReasonCode`(100), `Completed`(200)

### StepInputType Enum Values

`Barcode`(0), `Text`(1), `Numeric`(2), `DatePicker`(3), `Image`(4), `Choice`(5), `StatusInfo`(6), `Summary`(7)

## MobileItemMgt220FDW -- Item Resolution

Used to resolve a scanned barcode to an item and work with its metadata.

```al
var ItemMgt: Codeunit MobileItemMgt220FDW;

// Resolve a barcode to an item (sets ItemNo, VariantCode, UOMCode on State)
ItemMgt.Resolve(State.GetScannedValue(''));

// After Resolve() these are populated:
ItemNo      := ItemMgt.GetItemNo();
VariantCode := ItemMgt.GetVariantCode();
UOMCode     := ItemMgt.GetUOMCode();

// Convert a quantity from the scanned UOM to the base UOM
BaseQty := ItemMgt.ConvertToBaseUOM(ScannedQty, ItemNo, UOMCode);
```

`Resolve()` calls `Error()` if the barcode cannot be matched to an item — no manual error handling needed.

## ScanEntryMgt220FDW -- Recording Scan Operations

Scan entries are the audit trail of what the warehouse worker scanned and processed. Use `ScanEntryMgt220FDW` to create, update, and query them.

```al
var ScanEntryMgt: Codeunit ScanEntryMgt220FDW;

// Create a new scan entry for the current document line
ScanEntryMgt.CreateEntry(
    State.GetDocumentNo(true),
    State.GetLineNo(true),
    State.GetItemNo(true),
    State.GetLotNo(false),
    State.GetBinCode(false),
    State.GetQuantity());

// Mark all open entries for the document as posted
ScanEntryMgt.SetStatusPosted(State.GetDocumentNo(true));

// Get total scanned quantity for a document line
ScannedQty := ScanEntryMgt.GetScannedQuantity(
    State.GetDocumentNo(true),
    State.GetLineNo(true));

// Delete entries (e.g. on undo)
ScanEntryMgt.DeleteEntries(State.GetDocumentNo(true), State.GetLineNo(true));
```

Scan entries are stored in `Mobile Scan Entry 220FDW` table and are visible in BC for supervisor review.

## MobileRPCState220FDW -- Key Methods

### Request

| Method | Returns | Description |
|--------|---------|-------------|
| `Method()` | Text | RPC method name |
| `Service()` | Text | Service code |
| `Bundle()` | Guid | App bundle ID |
| `GetInput(key, showError)` | Text | Get input parameter by key |
| `GetScannedValue(AICode)` | Text | Barcode field by GS1 AI code; `''` = full raw value |
| `RawBarcode()` | Text | Always the complete raw barcode string |
| `GetCurrentStep()` | Enum StepType220FDW | Current workflow step type |

### Document Context

Getters take `ShowError: Boolean` — pass `false` to return blank instead of erroring when the value is not set.

Setters that take `UpdateInfoPane: Boolean` — pass `true` to immediately refresh the info pane field alongside the state change.

| Getter | Setter | Type |
|--------|--------|------|
| `GetDocumentNo(showError)` | `SetDocumentNo(val)` | Code[20] |
| `GetLineNo(showError)` | `SetLineNo(val)` | Integer |
| `GetItemNo(showError)` | `SetItemNo(val, updateInfoPane)` | Code[20] |
| `GetVariantCode(showError)` | `SetVariantCode(val)` | Code[10] |
| `GetLotNo(showError)` | `SetLotNo(val, updateInfoPane)` | Code[50] |
| `GetBinCode(showError)` | `SetBinCode(val, updateInfoPane)` | Code[20] |
| `GetLocationCode(showError)` | `SetLocationCode(val)` | Code[10] |
| `GetSelectedUOM(showError)` | `SetSelectedUOM(val, updateInfoPane)` | Code[10] |
| `GetQuantity()` | `SetQuantity(val, updateInfoPane)` | Decimal |
| `GetLastEnteredQty()` | *(read-only)* | Decimal |

### Response Building

| Method | Description |
|--------|-------------|
| `Serialize()` | Returns current state as JSON response |
| `Command()` | Returns MobileCommandBuilder220FDW for chaining commands |
| `UI()` | Returns MobileUIBuilder220FDW for building the InfoPane |
| `SetView(key, value)` | Set a view field (displayed in page via valueKey) |
| `UpdateViewField(key, value)` | Update a single InfoPane field in-place without rebuilding |
| `SetInfoPane(json)` | Set InfoPane component data directly |
| `SetScanInput(json)` | Set workflow step input configuration |
| `SetCommands(json)` | Set commands to send to client |
| `SetResult(key, value)` | Set a named result value in the response |

## JsonHelper220FDW -- Utility Methods

```al
// Reading from JsonObject
JsonHelper.ReadText(obj, 'key')      // -> Text
JsonHelper.ReadInteger(obj, 'key')   // -> Integer
JsonHelper.ReadDecimal(obj, 'key')   // -> Decimal
JsonHelper.ReadBoolean(obj, 'key')   // -> Boolean
JsonHelper.ReadDate(obj, 'key')      // -> Date  (parses ISO 8601)
JsonHelper.ReadObject(obj, 'key')    // -> JsonObject
JsonHelper.ReadArray(obj, 'key')     // -> JsonArray

// Writing to JsonObject
JsonHelper.WriteText(obj, 'key', 'value')
JsonHelper.WriteInteger(obj, 'key', 42)
JsonHelper.WriteDecimal(obj, 'key', 3.14)
JsonHelper.WriteBoolean(obj, 'key', true)
JsonHelper.WriteDate(obj, 'key', date)   // writes ISO 8601 "YYYY-MM-DD"
JsonHelper.WriteObject(obj, 'key', nestedObj)
JsonHelper.WriteArray(obj, 'key', arr)
JsonHelper.RemoveKey(obj, 'key')
JsonHelper.MergeInto(source, target)
```

## BC Configuration

### App Bundle (Mobile App Bundle 220FDW table)
- Code, Description, Root Page ID, Min Client Version
- Status: Dev(0) -> Test(1) -> Production(2) -> Archive(3)
- Production bundles load for all users automatically
- Dev/Test bundles require explicit user assignment in `Mobile User Assignment 220FDW`

### Service Registry (Mobile Service Registry 220FDW table)
- App Bundle ID + Mobile Service Code (composite key)
- Codeunit Id: the AL codeunit implementing IBCRPC220FDW

### Mobile Page (under App Bundle)
- Page ID, Service code, Page JSON (the full JSON page definition)

### Debugging
Enable **Request Logging** in `Mobile Setup 220FDW` to record every JSON-RPC request and response in the `Mobile Request Log 220FDW` table. Disable in production.

## MobileUIBuilder220FDW -- InfoPane

The info pane is the right-side detail panel. Build it via `State.UI()` — the result is serialized automatically by `State.Serialize()`. No need to call `SetInfoPane()` separately.

```al
State.UI()
    .InfoPane('Item Description', 'ITEM001')
    .WithItemImage('ITEM001')
    .Field(State.ItemLabel(), Item.Description)
    .Field(State.LotLabel(), LotNo)
    .Field(State.BinLabel(), BinCode)
    .Field(State.QuantityLabel(), Format(ScannedQty), "Tone::SUCCESS")
    .FieldQtyWithGauge(State.QuantityLabel(), ScannedQty, OutstandingQty);
```

| Method | Description |
|--------|-------------|
| `InfoPane(title, subtitle)` | Sets pane header text |
| `WithItemImage(itemNo)` | Loads the item picture from BC |
| `WithImageUrl(url)` | Sets an arbitrary image URL |
| `Field(label, value)` | Adds a plain label/value row |
| `Field(label, value, tone)` | Adds a row with colour highlight |
| `FieldQtyWithGauge(label, current, max)` | Progress bar row showing scanned vs outstanding |
| `HeaderRow(label, value)` | Full-width header row |

Use `State.UpdateViewField(key, value)` for incremental updates (e.g. after each scan) to update a single InfoPane field without rebuilding the full pane.

## Navigation

Navigate between pages using NavigateAction in JSON:
```json
{ "type": "navigate", "destination": "DETAIL-PAGE", "data": { "docNo": "{documentNo}" } }
```

Read navigation context on the target page's `Initialize`:
```al
DocNo := State.GetInput('docNo', false);
```

Programmatic navigation from AL:
```al
var Navigator: Codeunit MobileNavigator220FDW;
Navigator.NavigateToDetail(State, 'DETAIL-PAGE');   // push a page
Navigator.NavigateBack(State);                       // go back one page
Navigator.NavigateBackTo(State, 'LIST-PAGE');        // back to a specific page
```

Customise where a page code resolves (e.g. route to different pages based on document type) by subscribing to `MobileNavigator220FDW.OnResolveTarget`.

## MobilePage Enum Values (Extensible)

`Home`(0), `Detail`(1), `Receipt`(10), `PutAway`(20), `Pick`(30), `InventoryMovement`(40), `AdHocMovement`(50), `BinContents`(60), `Adjustments`(70), `ScanEntry`(80)

## Quick Checklist for New Features

1. Create AL codeunit implementing `IBCRPC220FDW`
2. Subscribe to `MobileRPCRegistry220FDW.OnResolveService`
3. Route methods in `RPC()` via `State.Method()`
4. Create JSON page definition — include `"key"` on every repeater
5. Wire `initialAction` (page load data) and `scanAction` (barcode handler) in contract
6. Return JSON data bound to page components via `valueKey` / `dataKey`
7. Register in Mobile Service Registry table; add page to App Bundle
8. For workflows: implement `IStepHandler220FDW`, subscribe to `WorkflowHandlerRegistry220FDW.OnResolveWorkflowHandler`, configure Workflow + Step tables
9. Create scan entries via `ScanEntryMgt220FDW.CreateEntry()` and mark posted on completion
10. Enable Request Logging in Mobile Setup while developing; disable before go-live

## Translations (Internationalization)

### How It Works

Page JSON uses `#token` markers for any text that must be translated. During `GetPageFlow`, `MobileRPCRegistry220FDW.TranslatePageJson` replaces every `"#TokenName"` with the translated value from the string map built by `MobileClientStrings220FDW`.

```json
{ "type": "button", "label": "#Post Receipt", "action": "post" }
```

The `#` prefix is stripped and replaced with the translated label at runtime.

### Tok vs Lbl Naming Convention

This is the most critical rule. Get it wrong and translations break silently or JSON keys corrupt.

| Suffix | `Locked` | Purpose | Used as |
|--------|----------|---------|---------|
| `Tok` | `true` | JSON data keys, binding tokens, API values | Never translated -- always the same string |
| `Lbl` | *(omitted)* | Display text, page labels, prompts | Translated via XLIFF |

```al
// CORRECT
var
    ItemNoTok: Label 'itemNo', Locked = true;    // JSON key -- never changes
    ItemNoLbl: Label 'Item No.';                  // Display label -- translated

// WRONG -- will corrupt JSON keys after translation
var
    ItemNoKey: Label 'itemNo';  // Missing Locked = true!
```

### Adding Custom Translated Strings

Subscribe to `OnRegisterExtensionStrings` on `MobileClientStrings220FDW`. The string map key is the **plain English text** — the same text you use in page JSON with a `#` prefix:

```al
[EventSubscriber(ObjectType::Codeunit, Codeunit::MobileClientStrings220FDW, OnRegisterExtensionStrings, '', false, false)]
local procedure RegisterMyStrings(var StringMap: Dictionary of [Text, Text])
begin
    StringMap.Set('Scan Item', ScanItemLbl);
    StringMap.Set('Post Receipt', PostReceiptLbl);
end;

var
    ScanItemLbl:    Label 'Scan Item';
    PostReceiptLbl: Label 'Post Receipt';
```

Then reference in page JSON as `"#Scan Item"` / `"#Post Receipt"`.

### 4-Step XLIFF Workflow

1. Declare the Label variable (no `Locked` = translatable)
2. Run **NAB: Refresh XLF Files** in VS Code to add entries to all language `.xlf` files
3. Add translated text in each language file
4. Set `state="translated"` on the trans-unit

### Date and Decimal Wire Format

**Dates** — always use `JsonHelper.WriteDate()` / `ReadDate()`. Never use `Format(Date)` — it is locale-sensitive and breaks client parsing.

```al
// ✅ Wire: "2025-12-31" (ISO 8601)
JsonHelper.WriteDate(Result, 'expiryDate', ExpiryDate);

// ✅ Reading a date sent from the client
ExpiryDate := JsonHelper.ReadDate(Params, 'expiryDate', true);

// ✅ User-visible date formatted for their locale ("31.12.2025" in de-DE)
var Localization: Codeunit MobileLocalization220FDW;
DisplayDate := Localization.FormatDateForDisplay(ExpiryDate);

// ❌ Wrong — locale-sensitive, breaks nl-NL parsing
JObj.Add('expiryDate', Format(ExpiryDate));
```

**Decimals** — write as a JSON number using `JsonHelper.WriteDecimal`. For display strings pass bare to `StrSubstNo`:

```al
// ✅ Wire: 3.5 (JSON number)
JsonHelper.WriteDecimal(Result, 'quantity', Quantity);

// ✅ Display: "3,5 ST" in German (StrSubstNo respects session locale)
StrSubstNo(EnterQtyLbl, Quantity, UOMCode)

// ❌ Wrong — produces "3.5" string even in nl-NL
JObj.Add('quantity', Format(Quantity, 0, 9));
```

### WorkDate vs Today

Always use `WorkDate()` for business date logic. `MobileRPCRegistry220FDW.ExecuteInterfaceHandler()` calls `MobileLocalization220FDW.ApplyUserWorkDate()` automatically before every service call — never call it manually.

```al
// ✅ User's local calendar date (warehouse worker in Frankfurt at 23:30 is still on their day)
if ExpiryDate < WorkDate() then Error(LotExpiredErr);

// ❌ Wrong -- server UTC date, not the user's local date
if ExpiryDate < Today then Error(LotExpiredErr);
```

### Critical Anti-Pattern

Never use a non-locked Label as a JSON object key. The string gets translated and breaks the key lookup:

```al
// WRONG -- 'Lot No.' becomes 'Lotnr.' in Danish, State.GetInput() returns empty
var LotNoKey: Label 'Lot No.';
JsonObj.Add(LotNoKey, LotNo);

// CORRECT
var LotNoTok: Label 'lotNo', Locked = true;
JsonObj.Add(LotNoTok, LotNo);
```
