dartvel_flutter 0.12.1
dartvel_flutter: ^0.12.1 copied to clipboard
The Flutter runtime for Dartvel apps, with pages, DVBox/DVText UI primitives, signals, typed routing, Studio, and SEO and PWA on the web.
dartvel_flutter #
The Flutter runtime of Dartvel: everything a Dartvel application runs on the device, in the browser and on the desktop.
DVBoxandDVText, styled through Dartvel's ownDVModifierchain.- Signals:
context.signal(...), signals derived with operators, reactive data models andDV.global. - The runtime behind file-based pages and typed routes: the page shell,
DVNavLink,DV.Navigation, layouts, loading and error pages. - The widgets generated data models use: forms, tables and model pages.
DV.Platform, the device API surface, reached throughdart:ffi, jnigen anddart:js_interoprather than platform channels.- On the web: head tags and structured data per page, path URLs, and the install prompt of the generated PWA.
- Dartvel Studio's page builder and admin sections, which a web-server build serves.
Dartvel is alpha. Each part of it is labelled in
docs/spec-status.json
as Shipped, Partial or Designed, and a check in the repository fails when a
section claims evidence that does not exist. This page shows Shipped
surfaces as ready to use and says so where one is Partial.
How it is used #
Most of this package is meant to be reached through code that Dartvel
generates. A Dartvel project is made with dartvel create. You write pages,
data models and backend functions as private annotated declarations, and the
CLI writes lib/dartvel_client/ from them: the router, one typed target per
page on DVRoutes, the public model classes and their forms, and the client
for each backend function. Application code imports that one barrel, which
re-exports this package:
import '../dartvel_client/dartvel_client.dart';
Generation runs by itself before dartvel dev and dartvel build, so you
rarely call it by name. The generated directory is not committed; a fresh
clone generates it on its first build.
Part of the package needs no generated code, and works in any Flutter app:
| Works on its own | Needs the generator |
|---|---|
DVBox, DVText, DVModifier |
Pages (@DVPage) and the router |
context.signal, signal(context, v), derived signals |
DVRoutes, and so DVNavLink and DV.Navigation |
DV.global, context.global |
Data models: User, User.Form(), User.Table(...), User.Page |
| Native bindings, which the generated app registers at startup | |
SEO, sitemap and PWA files, written by dartvel build web |
Routing is on the right on purpose. A route written as a string drifts the moment its page moves, and the generated targets turn that into a compile error. Without the generator there are no targets to use.
Install #
For a Dartvel application, install the CLI and let it write the project:
dart pub global activate dartvel_cli
dartvel create shop
cd shop && dartvel dev
The project it writes already depends on dartvel_flutter, dartvel_core
and dartvel_shelf. To use only the parts that need no generator in an
existing Flutter app:
flutter pub add dartvel_flutter
import 'package:dartvel_flutter/dartvel_flutter.dart';
Every Dartvel package declares the same floor: Dart 3.13 and Flutter 3.47.
dartvel init adds Dartvel to an existing Flutter project instead of making
a new one, and dartvel init --dry-run shows what it would change first.
A small app #
This is a generated project with two pages. lib/main.dart is what
dartvel create writes:
import 'package:flutter/material.dart';
import 'dartvel_client/dartvel_client.dart';
void main(List<String> arguments) async {
await negotiateDartvelLaunch(arguments);
runApp(createDartvelApp(arguments: arguments));
}
Widget createDartvelApp({List<String> arguments = const <String>[]}) {
return MaterialApp.router(
title: 'Shop',
routerConfig: createDartvelRouter(arguments: arguments),
);
}
lib/pages/index.dart is served at /:
import 'package:flutter/widgets.dart';
import '../dartvel_client/dartvel_client.dart';
@DVPage(title: 'Shop', description: 'House espresso, roasted this week.')
@pragma('vm:entry-point')
Widget _indexPage(BuildContext context) {
final DVSignal<int> quantity = context.signal(1);
final DVSignal<int> unitPrice = context.signal(1450);
final total = unitPrice * quantity;
return DVBox.list(<Widget>[
const DVText('House espresso').modifier(
DVModifier().fontSize(28).fontWeight(FontWeight.w700).semanticHeading(1),
),
DVText('${quantity.value} bags, EUR ${(total.value / 100).toStringAsFixed(2)}'),
const DVText('Add one').modifier(
DVModifier()
.paddingSymmetric(horizontal: 16, vertical: 10)
.rounded(8)
.semanticButton()
.onTap(() => quantity.value = quantity.value + 1),
),
DVNavLink(to: DVRoutes.about, child: const DVText('About the roastery')),
]).modifier(const DVModifier().padding(24));
}
lib/pages/about.dart is served at /about, and generating it is what puts
DVRoutes.about on the index page:
import 'package:flutter/widgets.dart';
import '../dartvel_client/dartvel_client.dart';
@DVPage(title: 'About')
@pragma('vm:entry-point')
Widget _aboutPage(BuildContext context) => const DVBox.list(<Widget>[
DVText('We roast on Tuesdays.'),
]);
Two rules the generator enforces. The annotated function is private
(_aboutPage, not aboutPage), because Dartvel generates the public API from
it and a public annotated input is an error. And a page does not build its own
Scaffold: @DVPage owns the page shell, so title, showAppBar and the
rest go on the annotation.
example/ in this package is a runnable app that uses only the parts that
need no generator.
UI: DVBox, DVText and the modifier chain #
DVBox is the one layout primitive and DVText the one text primitive. Both
take a DVModifier, an immutable chain of styling, layout, interaction and
semantics. The chain is Dartvel's own, rendered with Flutter widgets; it is
not built on mix.
Widget card() => DVBox(
const DVText('One child, with padding and a border'),
const DVModifier()
.padding(16)
.backgroundColor(Color(0xFFF4F6FB))
.border(Border.fromBorderSide(BorderSide(color: Color(0xFFE3E7EF))))
.rounded(12),
);
DVBox(child) is for one child. A collection uses a named constructor, so the
layout is said once, where the children are:
Widget layouts(BuildContext context) => DVBox.list(<Widget>[
const DVBox.row(<Widget>[
DVText('Left'),
DVText('Right'),
], align: DVAlign.spaceBetween),
const DVBox.wrapLine(<Widget>[
DVText('Dart'),
DVText('Flutter'),
DVText('Web'),
], spacing: 12),
// Up to four columns, fewer on narrow screens.
DVBox.grid(<Widget>[
for (int i = 1; i <= 8; i++) DVText('Item $i'),
], columns: context.screen.value<int>(mobile: 1, tablet: 2, desktop: 4)),
], spacing: 24);
The others are DVBox.scrollableList, DVBox.horizontalScrollable,
DVBox.stack, DVBox.masonry and DVBox.twoPane, which splits across a
foldable's hinge and stacks on a phone. DVBox.image, DVBox.video and
DVBox.audio are boxes whose content is media.
Interaction and semantics are on the same chain, so a tappable label is a
DVText rather than a button widget wrapped round one:
Widget saveButton(VoidCallback onSave) => DVText('Save').modifier(
const DVModifier()
.paddingSymmetric(horizontal: 20, vertical: 12)
.backgroundColor(Color(0xFF2F6BFF))
.color(Color(0xFFFFFFFF))
.rounded(8)
.animate(Duration(milliseconds: 150))
.hover(DVModifier().opacity(0.9))
.minimumTapTarget()
.semanticButton()
.onTap(onSave),
);
The Styling section is Partial: the chain covers padding, borders, radius,
colour, gradients, type, shadows, opacity, blur, rotation, hover and
animation, and the named design tokens the spec describes are not built yet.
Signals #
A signal is a value a widget reads; reading .value in build redraws that
widget when the value changes. context.signal(initial) creates one for the
widget, and signal(context, initial) is the same call as a function.
Signals are matched by call order in build, like hooks, so create them
unconditionally.
final DVSignal<int> quantity = context.signal(1);
quantity.value = 3; // set
quantity.update((int n) => n + 1); // set from the current value
final int now = quantity.read(); // read without subscribing
An operator on a signal returns a signal that tracks its sources. There is no separate computed type:
final DVSignal<int> price = context.signal(1200);
final DVSignal<int> quantity = context.signal(2);
final DVSignal<bool> agreed = context.signal(false);
final DVSignal<bool> paid = context.signal(false);
final subtotal = price * quantity; // + - * / ~/ % on numbers
final expensive = subtotal >= 10000; // < <= > >= give a bool signal
final canShip = agreed & paid; // & | ^ on bools
Read .value of a derived signal in build to redraw with it. == and !
are not overloaded, so compare .value instead.
One object shared across the app is registered with DV.global. There is no
separate service container:
DV.global<Cart>(Cart()); // register, at startup
final Cart cart = DV.global<Cart>(); // read anywhere; throws if unregistered
final Cart same = context.global<Cart>(); // read, and redraw when it is replaced
A generated data model is reactive too: article.signal(context) gives a
signal of that record, and Article.Page.signal(...) redraws the model's page
from it.
Pages and routing #
A file in lib/pages/ is a page. lib/pages/blog/[id].dart is served at
/blog/:id, and its parameters and query are on the context:
@DVPage(title: 'Blog post')
Widget _blogPostPage(BuildContext context) => DVBox.list(<Widget>[
DVText('Post ${context.dvParams['id']}'),
DVText('Sorted by ${context.dvQuery['sort'] ?? 'date'}'),
]);
Every page is a typed target on the generated DVRoutes, and navigation takes
only targets:
DVNavLink(to: DVRoutes.about, child: const DVText('About us'))
DVText('Open post 7').modifier(
DVModifier().onTap(DV.Navigation.to(DVRoutes.blog(id: '7'))),
)
// A query is still a typed route: both halves are generated targets.
DV.Navigation.to(
DVRoutes.signin.withQuery(<String, String>{'from': DVRoutes.account.path}),
)
DV.Navigation.to returns a callback rather than navigating, so it goes where
a handler is expected. Written as onTap: () => DV.Navigation.to(target) it
builds the callback and throws it away; the analyzer flags that through
@useResult. DV.Navigation.navigate, push and back act immediately.
DVNavLink is a link, not a tap handler. It navigates with its padding as
part of the hit area, is announced to a screen reader as a link carrying its
destination, takes keyboard focus and answers Enter, opens beside the page on
a middle or modifier click, preloads the route's deferred code when it
becomes visible, and shows a preview of the destination on a resting pointer
or a long press. DVNavLink.external(url, child: ...) is the same for an
address outside the app.
Beside the pages:
_layout.dartin a folder wraps every page under it, as a class extendingDartvelLayout.about.loading.dartandabout.error.dartare shown whileabout.dart's data loads and when it fails._guard.dartruns before every page in its folder, and@DVPage(policy: ...)puts a page behind an authorization policy.
Every page can be reached by keyboard, a TV remote's D-pad and switch control
with nothing added: the page shell carries keyboard scrolling, D-pad focus
and switch scanning, and they stay inert until used. The reader's own choices
live on DV.Accessibility.switchControl:
DV.Accessibility.switchControl.enabled = true;
Data models and forms #
A data model is declared once, as a private class, and the generator writes
the public class with its schema, CRUD, validation and serialization. The
annotation comes from dartvel_core; the widgets the model gets are built on
this package:
// lib/models/article.dart
import 'package:dartvel_core/dartvel.dart';
@DVModel()
class const _Article({
required final String slug,
required final String title,
required final bool published,
});
Widget newArticleForm() => Article.Form(); // creates a record
Widget editArticleForm(Article a) => a.Form(); // edits this record
Widget articleTable(List<Article> all) => Article.Table(all);
Widget articlePage(Article a) => Article.Page.sync(a);
A form takes no save callback. Saving is what the form does, and whether the reader may create or edit is answered by the model's policy, the same one the page and the backend function ask.
Device APIs #
DV.Platform is one surface on every target. A feature the platform does not
have reports that it is unavailable, and calling a name nothing has bound
throws and names it, rather than returning a plausible default.
Future<void> shareReceipt(String orderId) async {
await DV.Haptics.lightVibrate();
await DV.Share.shareText('Order $orderId is on its way.');
}
Connectivity is a signal, so an offline banner is a widget that rebuilds:
final DVNetworkStatus status = DV.Platform.network.watch(context);
// online, metered, offline or unknown
The bindings reach native code through dart:ffi (X11, GTK and GDBus on
Linux, the Win32 API, the Objective-C runtime on Apple platforms),
dart:js_interop on the web and jnigen on Android. There are no platform
channels. Coverage differs by platform, by design and by progress: a browser
tab has no system tray, and on Android biometrics and NFC need an Activity
rather than a Context. The Platform section is Partial, and its entry in
docs/spec-status.json lists what each target binds and why the rest is
absent. The generated app registers the bindings for its target at startup.
Media and the camera #
Playback and the camera are box modes. The box's element owns the player or the camera, so leaving the page releases the decoder, audio focus and the camera light:
Widget trailer() => DVBox.video(
const DVMediaSource.url('https://cdn.example.com/trailer.mp4'),
captions: DVCaptions.track(
'en',
const DVMediaSource.url('https://cdn.example.com/trailer.en.vtt'),
),
session: const DVMediaSession(title: 'Launch trailer'),
);
Widget scanner(void Function(DVFile photo) keep) =>
DVBox.camera(lens: .back, onPhoto: keep);
Captions are text over the picture, so screen readers and Ctrl+F reach them.
The standard controls are labelled and keyboard-operable. A DVMediaSession
puts the player on the lock screen. Remote files go through a disk cache, and
DV.Platform.media.precache(source, bytes: ...) fetches the start ahead of
time. Which targets have a player, a camera, lock-screen controls and
picture-in-picture is listed under Media Playback and Capture in
docs/spec-status.json; elsewhere the box reports that nothing is bound
rather than drawing an empty rectangle.
The web: SEO and PWA #
A page's title and description on @DVPage become its title, meta
description, Open Graph tags and structured data. dartvel build web writes
one prerendered HTML page per route, with those head tags and text a crawler
can read, plus sitemap.xml and robots.txt. Routes behind a guard are left
out of the sitemap. A page can tune its own entry:
@DVPage(
title: 'Pricing',
sitemap: DVPageSitemap(
priority: 0.8,
changeFrequency: DVSitemapChangeFrequency.weekly,
),
)
Widget _pricingPage(BuildContext context) => const DVText('Plans');
The same build writes the PWA: manifest, icons, service worker and an offline
page. A same-origin request other than a GET that cannot be sent while
offline is queued by the worker and sent in order when the network returns. The install prompt is on DV.Platform:
if (DV.Platform.install.canPrompt) {
final DVInstallOutcome outcome = await DV.Platform.install.prompt();
}
canPrompt is false until the browser says the app is installable, and
prompt() must run inside a user gesture, because browsers refuse it
otherwise.
URLs are paths rather than Flutter's default #/ fragments, so a deep link
reaches the page it names.
Studio #
Studio inherits the application’s effective Material theme. New projects use
dartvelDefaultTheme(.light) and dartvelDefaultTheme(.dark), shared with the
Dartvel site, including its bundled Manrope font. Full theme parity is still
partial: Studio’s custom color tokens and the visible server-rendered first
frame have not yet been migrated.
Dartvel Studio is a visual page builder and data editor. It lives in this
package and is served by a web-server build at /__studio, to accounts that
have been granted access; it is never compiled into an app:
dartvel:
admin:
enabled: true
dartvel build web-server
dartvel admin grant <user-id> --database dartvel_data/data.db
In a web app served by its own server, a page published from Studio takes over its route on the next load without a rebuild, and reverting it gives the route back to the compiled page. A page exported from Studio is ordinary Dartvel source.
Studio is a set of routes of the application rather than a second application
beside it, on the same render path as every public page: Ctrl+F has text to
match, a drag selects, the arrows scroll, and a remote's D-pad and switch
control move the focus. Every screen has its own address under the mount —
<mount> is Pages, <mount>/<screen> is that screen and
<mount>/<screen>/<object> opens one thing inside it — so a screen can be
linked, bookmarked and reloaded, and each is also served as a document a
reader without the app can read.
Studio's own controls answer the keyboard: every control is drawn from
DVStudioIconButton, DVStudioControl or DVStudioSwitch, so it takes the
focus, Enter and Space both press it, a screen reader is told its role and its
name, and the focus is drawn as a ring on the control itself. A control with
nothing to do — Undo with no history, Publish while publishing — says so and is
not a focus stop, rather than looking live and doing nothing. What these three
widgets are and what they deliberately do not cover is written down in
docs/studio/PARITY.md.
Sign-in uses the same focusable control. Tab from the password reaches Sign
in; Enter activates it. Submitting the password field also signs in, and both
fields expose their labels to assistive technology.
The readable /data, /sitemap and /team aliases under the Studio mount
preserve existing /models, /routes and /access links. Data record forms
open at <mount>/data/<model>/<record> and report stale links explicitly.
Platform notes #
-
Web. dartvel.dev is built with this package, and runs as a static build.
-
Desktop (multi-window is
Partial).DV.Platform.window.open(route)never fails: where a real window cannot be created it navigates to the route instead and reports why. Real OS windows need the separatedartvel_windowingpackage, which is not published, because it depends on Flutter's internal windowing API. -
Tray and menu-bar applications.
DV.Platform.tray.show(...)puts up an icon on macOS (status bar), Windows (notification area) and Linux (StatusNotifierItem); its menu takes separators, disabled header lines, check and radio marks and submenus, andtray.update(...)changes the icon, tooltip or menu in place. SetDVWindowManager.exitPolicy = .explicitand closing the main window hides it instead of quitting;DV.Platform.window.show()and.hide()bring it back and send it away, andDV.Platform.launchAtLoginstarts the app at login:await DV.Platform.tray.show( icon: DVAsset.trayDisconnected, template: true, // macOS draws it in the menu bar's own colour tooltip: 'Disconnected', onActivate: () => DV.Platform.window.show(), // Windows and Linux click onSelected: (String id) { /* by id, submenus included */ }, menu: const <DVTrayMenuItem>[ DVTrayMenuItem.header('Disconnected'), DVTrayMenuItem.separator(), DVTrayMenuItem(id: 'profiles', label: 'Profiles', children: <DVTrayMenuItem>[ DVTrayMenuItem(id: 'home', label: 'Home', checked: true, radio: true), ]), DVTrayMenuItem(id: 'quit', label: 'Quit'), ], ); await DV.Platform.tray.update(icon: DVAsset.trayConnected, tooltip: 'Connected'); -
Terminal (
Partial).dartvel build linux-clirenders the same app in a terminal. It is opt-in at build time, so no other build links terminal code. -
Embedded and TV targets (Tizen, webOS, Sony eLinux, tvOS) are built through each vendor's embedder. Status and evidence per target are in
docs/build-targets.md; some targets have been built and not run, and Fuchsia does not build.
Links #
- API reference
- Repository
- Getting started
- NEW_SPEC.md, the design specification. It describes where Dartvel is going; where it and the code disagree, the code wins.
- Implementation status per spec section
- dartvel.dev
The name #
The framework is Dartvel and the command is dartvel. The pub.dev package for
the CLI is dartvel_dev because dartvel was taken on 2026-08-06 by an
unrelated package.
Forms and accessibility (framework-level) #
DVVisibilityToggle.eye/.none/.custom(builder)controls the visibility toggle on sensitive fields..input(obscureText: true)gets the eye by default;.noneturns it off and.custom(builder)replaces it with a control of your own.- Every
DVFormis keyboard-accessible with nothing added: Tab walks the fields and controls in draw order, Enter moves to the next field and submits from the last one, and every.onTap()/.onPressed()control is a focusable button with a visible focus ring and a screen-reader name. A refused save is announced in a live region and puts the focus on the field at fault.DVFormScopeis the public piece for inputs a form does not draw itself.