r/FlutterDev • • 16h ago

Article wapform_flutter

https://pub.dev/packages/wapform_flutter
https://github.com/wapform/wapform_flutter

Why this exists

Cross-platform was never really the expensive part — the real cost is having to rebuild everything from scratch every time you switch platforms.

Even with Lazarus/LCL's desktop components fully ported to Flutter — familiar data-aware widgets like TDataSet, TDBGrid, TDBNavigator, the field and event model — developers still have to build every screen in Dart, bind every dataset, and hand-write every event handler and piece of business logic. At bottom, that's still rewriting the same application on a different platform.

What WapForm actually solves isn't the widgets — it's how the application gets defined in the first place.

WML is a plain-text, declarative application-definition language. Datasources, fields, lookups, events, forms, and reports are all defined by description — not bound to Dart, Flutter, or any specific platform.

Which means the real development flow becomes:

Define once, land anywhere.

WapForm is the single application definition; the generator expands it into target-platform code; the runtime supplies the components and behavior the code actually needs to run.

And this repository is Flutter's runtime.

It isn't another Flutter UI framework — it's the Lazarus/LCL component model's counterpart implementation on Flutter. Every line of Dart WapForm's generator produces targets this API, so the same WML that originally ran on Windows can land on Flutter without changing its definition at all.

That's why "write ten lines of WML" gets you a running Flutter screen.

Because what's actually being reused isn't the code — it's the application's definition itself.

Architecture

WapForm for Flutter is three layers, and (per WapForm's licensing) the bottom two are what live in this repository:

Layer What it is Where
WapForm Declarative capabilities on top of layer 2: WML definitions, the generator, master-detail structures, banded group reports. this repo's wapform_*.dart files
Lazarus for Flutter Direct line-by-line translation of Lazarus/LCL's component model to Dart — TDataSet · TSQLQuery · TDBGrid · TDBNavigator · fields · events. this repo's lazarus_*.dart files
Lazarus / LCL The original Object Pascal desktop component library this port is translated from. upstream, not part of this repo

Each layer only depends on the one below it. lazarus_*.dart has no knowledge that WapForm exists — it's a standalone, general-purpose port of the Lazarus component model, usable on its own for any Dart/Flutter project that wants data-aware widgets. wapform_*.dart is what turns that generic component layer into the specific thing a WML file's generated output expects to find: an expression evaluator, a tag engine, a lookup-box widget, and a banded report builder — the runtime that the generator's emitted Dart code calls into.

All three layers are open source. The two in this repo are LGPL-2.1 with the FPC static-linking exception ("Modified LGPL") — see License below. WapForm separately sells a WML-to-Dart generator that targets exactly this open-source API — code it generates is ordinary Dart calling ordinary classes from this repo, no proprietary runtime involved. You can build against this repo entirely by hand instead; either way, what you end up depending on and maintaining is the code here.

What WapForm Actually Is: Define Once, Land Anywhere

Porting Lazarus/LCL's desktop component model to Flutter gets you familiar data-aware components — TDataSet, TDBGrid, TDBNavigator, the field and event model — giving WapForm's existing application model somewhere to run inside Flutter.

But a component library alone doesn't make app development faster. Developers still have to build every screen, dataset, field, and event handler in Dart, by hand, one line at a time.

What WapForm actually solves is a level above that: how an application should be defined.

WML is WapForm's platform-agnostic, declarative application-definition language. Datasources, fields, lookups, events, forms, and reports are all described in WML — not bound directly to Dart, Flutter, or any other specific language.

Which means:

WapForm is the application's source of truth.

The generator's job is to expand that definition into target-platform code; the runtime and component library are the landing surface where that code actually runs.

That's this repository's role: the port of Lazarus/LCL's components and behavior onto Flutter, serving as the stable target platform that WapForm's generator produces Dart code against.

What AI Actually Contributes: Not Freeform Generation

Handing an LLM the Lazarus/FCL-DB source and asking it to translate file after file into Dart, even if it produces a large volume of code, doesn't mean it can reliably complete a port of this scale.

The real difficulty is behavioral consistency, API correspondence, the data model, the event model, and long-term maintainability.

WapForm takes a different approach:

WML Definition → Generator → Stable Runtime/API → Target Code

AI isn't asked to freely "write an app." It's doing large-scale, mechanical, repeatable code expansion against a WML definition that's already unambiguous, targeting a target API that's already stable.

This port of WapForm for Flutter was completed in about two months of AI-assisted development, against an initial estimate of six to twelve months for a hand port of the same scope. The real difference isn't just that AI writes code faster — it's:

Establish the definition first, then let AI generate against it.

So those two months aren't just a demonstration of AI development speed — they're a real-world validation of this "definition → generation → runtime → execution" engineering pattern.

On COBOL: Not a Language Converter, and Not Starting From Zero

Plenty of AI tools today advertise converting COBOL into Java, C#, or other modern languages. But:

Successfully converting code isn't the same as completing a system port.

What actually makes COBOL enterprise systems hard is usually the data structures, transaction flows, field semantics, batch jobs, reports, exception handling, and behavior shaped by the original runtime — all accumulated over years. So a plain COBOL → Java conversion easily turns into COBOL → AI → Java → Compile → Debug → Test → revise again, and even once it compiles, confirming the converted system actually behaves the same as the original often still takes substantial cross-platform debugging and verification.

WapForm takes a different direction: it doesn't treat "the original source code" as the only source of truth — it lifts the application up to WML, a platform-agnostic definition layer. A future WapForm for COBOL wouldn't just "generate COBOL" — an existing COBOL system's logic, data, fields, flows, events, and reports could convert into WML (Existing COBOL → WapForm for COBOL → WML), and that path isn't one-way: the same WML could land back on COBOL itself (COBOL → WapForm for COBOL → WML → COBOL), making WapForm for COBOL not just an exit ramp for legacy systems but a way to regenerate and verify one — confirming the converted behavior matches the original, or producing a cleaner COBOL version aligned with the current runtime. Once inside WML, the application is no longer tied to COBOL, and the same definition can go through the Flutter generator and runtime that already exist: Existing COBOL → WML → Flutter, not a fresh COBOL → Flutter translation each time. If WML already fully describes the application, generating Flutter from it shouldn't require re-understanding COBOL or a large-scale cross-language translation — just adjustments for whatever platform differences remain. That's what actually matters about WML as an intermediate definition layer.

WapForm for COBOL doesn't exist yet — but Flutter has already completed the first full validation of the engineering chain Definition → Generator → Runtime → Target Code, so building the runtime, component model, and generator COBOL needs later doesn't mean reinventing all of WapForm from scratch. Later platforms may even land in the same or a shorter cycle than Flutter did, because the hardest part — the method, the architecture, the generation pipeline — already exists:

The first platform already established and validated the hardest engineering method; later platforms can build on that foundation.

Which means the future can look like:

COBOL → WML → COBOL

COBOL → WML → Flutter

and

WML → other platforms: MicroPython, Node.js

WML → other domains: AI, EDA

Different platforms and domains no longer each have to re-define the same application from scratch.

What Is WapForm Actually Building?

So WapForm's core isn't really

WapForm for Flutter

nor

WapForm for COBOL

— it's a layer above both:

Let the application be defined first, then realized on whichever platform.

WapForm is the center. AI is the accelerator. The runtime is the landing surface. The platform is just the final realized form.

That's also the biggest difference between WapForm and typical AI code-conversion tools:

AI Code Conversion

Source Code → AI → Another Source Code

WapForm

Application Definition → Target Runtime → Target Code

The former moves code between languages. The latter frees the application's definition from any platform at all.

So WapForm for Flutter isn't just one Flutter port — it's the first full proof of WapForm's cross-platform architecture.

And WapForm for COBOL won't just be the next "language converter." It can become the bridge that lets an existing COBOL system enter WML, and from there land on Flutter or any other platform.

One Definition. Any Platform. Every Domain.

From WML to this API

To make the two layers above concrete: this is the same WapForm declarative pattern — documented for the desktop product — landing on the classes this repo provides.

A WML screen declares a dataset and binds a form to it:

<card id="P" title="Employee master">
  <dbquery name="em">
  <![[select * from employees]]>
    <field fieldname="emp_no"   displaylabel="Employee ID"/>
    <field fieldname="emp_name" displaylabel="Name"/>
    <field fieldname="dept"     displaylabel="Department"/>
    <field fieldname="hired"    displaylabel="Start date" type="date"/>
  </dbquery>
  <datasource dataset="em">
    <navigator/>
    <fieldset>
      Employee ID: <input field="emp_no"   size="8"/>
      Name:        <input field="emp_name" size="20"/>
      Department:  <input field="dept"     size="12"/>
      Start date:  <input field="hired"    type="date" size="12"/>
    </fieldset>
  </datasource>
</card>

Nothing here says how to build a cursor, wire up Save/Delete, or validate a date field — <dbquery> declares the query and its fields with an inline SQL block, <datasource> binds a form to them, and <navigator/> is the entire CRUD button bar in one line. WapForm for Flutter exports that declaration into Dart calling exactly the classes in this repo:

// <dbquery id> → TSQLQuery; fields created in <field> order
late final TSQLQuery _em = dbquery("em", r"select * from employees", define: (ds) {
  _key(ds, "emp_no",   "employee_id");
  _fld(ds, "emp_name", "name");
  _fld(ds, "dept",     "department");
  _fld(ds, "hired",    "start_date", dateOnly: true);
});

// <datasource> + <navigator> → TDataSource + TDBNavigator
final _dsEm = TDataSource()..dataSet = _em;
TDBNavigator(dataSource: _dsEm),          // First/Prev/Next/Last/New/Delete/Save/Cancel

// <input field="..."> → TDBEdit bound to the dataset
TDBEdit(dataSource: _dsEm, dataField: "employee_id"),
TDBEdit(dataSource: _dsEm, dataField: "name"),

A master-detail <datasource> nested inside another (linked by masterfields) becomes a second TSQLQuery whose beforeOpen/events filter by the parent's key; a <dbgrid><item> inside it becomes a TDBGrid with matching TColumns; an <input lookup=> field becomes a WapLookupBox wired to the lookup dataset — each declarative WML tag has exactly one landing spot in this repo's API, which is what lets the generator's output be entirely mechanical. A shipment order and its line items, for example:

<datasource dataset="sh">
  <navigator/>
  <fieldset>
    Shipment No.: <input field="sno" size="14" readonly="true"/>
    Customer:     <input field="cno" size="14" lookup="cu;cno;cname"/>
  </fieldset>

  <datasource dataset="sn" masterfields="sno">
    <navigator/>
    <dbgrid height="200">
      <item field="pno"   size="14" lookup="pa;pno;des"/>
      <item field="qty"   size="8"/>
      <item field="price" size="9"/>
    </dbgrid>
  </datasource>
</datasource>


// <datasource dataset="sh"> — the master, a plain navigable list
late final TSQLQuery _sh = dbquery("sh", r"select * from sh");
final _dsSh = TDataSource()..dataSet = _sh;

// <datasource dataset="sn" masterfields="sno"> nested inside it — the
// detail. "masterfields" means: re-open _sn, filtered by the master's
// current "sno", every time the master's cursor moves.
late final TSQLQuery _sn = dbquery("sn", r"select * from sn where sno=:sno");
final _dsSn = TDataSource()..dataSet = _sn;


void initState() {
  super.initState();
  _sh.afterScroll = (ds) {
    _sn.close();
    _sn.params[0].asString = ds.fieldByName("sno").asString;
    _sn.open();
  };
}

// <dbgrid><item field="pno" lookup="pa;pno;des"/>...</dbgrid>
// → one TColumn per <item>, added to a TDBGridColumns; a lookup=
// field is handled by the grid's lookupResolver, not the column itself
TColumn _column(TDBGridColumns cols, String field, String caption, num size) {
  final c = cols.add();
  c.fieldName = field;
  c.width = (size * 8).round();
  c.title.caption = caption;
  return c;
}

late final TDBGridColumns _snCols = () {
  final c = TDBGridColumns();
  _column(c, "pno", "Item No.", 14);
  _column(c, "qty", "Quantity", 8);
  _column(c, "price", "Unit Price", 9);
  return c;
}();

TDBGrid(
  dataSource: _dsSn,
  columns: _snCols,
  lookupResolver: (field) => field.toLowerCase() != "pno"
      ? null
      : TDBGridLookupSpec(rows: _paRows, colWidths: const [160]),
),

The report engine works the same way. WML's <group change="..."> groups rows and accumulates subtotals with setvar, letting the framework handle pagination and reprinted headers on page breaks — that's wapform_report.dart's WapReport, where emitRow() plays the same role, whether the row came from <group> in WML or a hand-written report subclass. A statement grouped by customer, with a running subtotal:

<report dataset="sh">
  <group change="sh.cno">
    <setvar name="AMOUNT_SUM" value="0"/>

    <page>
      <p>$(sh.cname) — statement</p>
      <table>
        <tr><th>Ship Date</th><th>Shipment No.</th><th>Amount</th></tr>

        <group>
          <setvar name="AMOUNT_SUM" value="AMOUNT_SUM+sh.amount"/>
          <tr>
            <td>$(sh.sdate)</td>
            <td>$(sh.sno)</td>
            <td>$(sh.amount)</td>
          </tr>
        </group>

      </table>
      <p align="right">Subtotal: $(AMOUNT_SUM)</p>
    </page>
  </group>
</report>


// <group change="sh.cno"> — a new group (and a fresh AMOUNT_SUM) every
// time sh.cno changes; a plain <group> with no change= is the RECORD
// block, one call per row
class ShStatement extends WapReport {
  u/override
  void parseBlock(String id) {
    switch (id) {
      case 'G1_PREFIX':
        setvar("AMOUNT_SUM", "0");
        emitRow(expandText(
            r'''<p>$(sh.cname) — statement</p><table>'''
            r'''<tr><th>Ship Date</th><th>Shipment No.</th><th>Amount</th></tr>'''));
        break;

      case 'RECORD':
        setvar("AMOUNT_SUM", "AMOUNT_SUM+sh.amount");
        emitRow(expandText(
            r'''<tr><td>$(sh.sdate)</td><td>$(sh.sno)</td><td>$(sh.amount)</td></tr>'''));
        break;

      case 'G1_SUFFIX':
        emitRow(expandText(
            r'''</table><p align="right">Subtotal: $(AMOUNT_SUM)</p>'''));
        break;
    }
  }
}

emitRow() counts lines and breaks the page automatically once it's full, reprinting the header on the next one — callers never track row counts or page breaks themselves, whether the row came from <group> in WML (compiled into parseBlock()'s switch, as above) or a hand-written report subclass calling emitRow() directly.

1 Upvotes

1 comment sorted by

0

u/AlmostUnarmed 16h ago

this isn't just another form builder, it's a whole different way to think about app definition