Day for Flutter developers

This page maps Flutter’s vocabulary onto Day’s, shows the same code in both, and covers the four places the workflow differs: state, packages, resources, and builds.

Flutter draws its own widgets; Day uses the platform’s. Flutter ships a renderer (Impeller/Skia) and repaints every pixel, so your app looks identical everywhere. Day creates each platform’s real widgets (NSButton on macOS, a Material button on Android, GTK, Qt, WinUI, and ArkUI widgets elsewhere), so your app looks native everywhere instead. Why Day lists what each approach costs and when Flutter is the better pick.

Flutter terms in Day

FlutterDay
Widget subclassa plain function returning a Piece
StatelessWidgetany function — there’s no class hierarchy
StatefulWidget + Statea function that creates Signals
setState(() { … })count.set(…) / count.update(…)
build() re-runs on changenothing re-runs; one binding patches one widget
ValueNotifier / Provider / RiverpodSignal and Memo, built in
const constructors, keysnot needed — the tree is built once
Navigator / go_routerselector, stack, and string routes
pubspec.yamlCargo.toml (package) + Day.toml (app manifest)
assets section in pubspecthe resource/ directory, by convention
ARB files + intl codegenFluent .ftl files + generated res::str functions
platform channelsparts — direct FFI, no message codec
flutter build apk / ipaday pack -p android-mdc / -p ios-uikit
hot reloadnone — day relaunch rebuilds and relaunches

The same counter in both

Both build the same UI; they handle the tap differently. Flutter marks the State dirty and re-runs build(); Day re-runs only the label’s closure and sets one native string.

FlutterDay
class Counter extends StatefulWidget {
  const Counter({super.key});
  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$_count clicks'),
        FilledButton(
          onPressed: () => setState(() => _count++),
          child: const Text('+'),
        ),
      ],
    );
  }
}
use day::prelude::*;

fn counter() -> AnyPiece {
    let count = Signal::new(0i64);
    column((
        label(move || format!("{} clicks", count.get())),
        button("+").action(move || count.update(|c| *c += 1)),
    ))
    .spacing(12.0)
    .padding(16.0)
    .any()
}

The Day version needs no State class and no BuildContext, and const optimization doesn’t exist because nothing rebuilds. Signal<T> is Copy, so moving it into two closures needs no .clone() ceremony. The function runs once; after that, updates flow through the signal graph. Reactivity explains the machinery.

Form controls: controllers vs. two-way signals

Flutter’s text input wants a TextEditingController you create, dispose, and listen to. Day’s input Pieces bind a signal directly: edits write the signal, signal writes update the widget, with no echo loop.

FlutterDay
final _name = TextEditingController();
double _volume = 40;
bool _subscribed = false;

@override
void dispose() {
  _name.dispose();
  super.dispose();
}

Widget build(BuildContext context) {
  return Column(children: [
    TextField(
      controller: _name,
      decoration: const InputDecoration(hintText: 'Your name'),
    ),
    Slider(
      value: _volume,
      max: 100,
      onChanged: (v) => setState(() => _volume = v),
    ),
    Switch(
      value: _subscribed,
      onChanged: (v) => setState(() => _subscribed = v),
    ),
  ]);
}
let name = Signal::new(String::new());
let volume = Signal::new(40.0);
let subscribed = Signal::new(false);

column((
    text_field(name).placeholder("Your name"),
    slider(volume).range(0.0..=100.0),
    toggle(subscribed),
))
.spacing(12.0)

There is nothing to dispose: every signal and handler belongs to a scope, and when the page that created it goes away, the scope tears it all down.

Lists

ListView.builder maps to list, Day’s recycling list. Rows are built on demand and reuse native cells, so a long feed doesn’t build a widget per item. For short, fully-materialized collections, each is the keyed non-recycling form.

FlutterDay
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, i) => ListTile(
    title: Text(items[i]),
  ),
)
let items = Signal::new(vec![
    "Milk".to_string(),
    "Eggs".to_string(),
]);

list(
    move || items.get(),
    |s| s.clone(),
    |slot: ItemSlot<String, String>| {
        label(move || slot.get()).padding(8.0)
    },
)

The second argument is the key function: the job Flutter’s Keys do, made mandatory so reconciliation is always by identity, never by index.

go_router declares routes as configuration. Day models navigation as state you own: a selector projects a one-of-N signal onto a sidebar or tabs, and a stack projects a Vec<String> path onto the platform’s push/pop container (UINavigationController on iOS, the Android back stack), so the edge-swipe and back button come from the OS, not a package.

FlutterDay
final router = GoRouter(routes: [
  GoRoute(
    path: '/',
    builder: (context, state) => const HomePage(),
    routes: [
      GoRoute(
        path: 'album/:id',
        builder: (context, state) =>
            AlbumPage(id: state.pathParameters['id']!),
      ),
    ],
  ),
]);

// push
context.go('/album/42');
let path = Signal::new(Vec::<String>::new());

stack(path, home_page())
    .title("Home")
    .destination(|key| album_page(key))

// push (from anywhere)
navigate("album-42");
// or edit the state directly:
path.update(|p| p.push("album-42".into()));

Because the path is a plain signal, deep links, state restoration, and tests are all string writes: DAY_DEEPLINK=library/album-42 on launch, assert_route in a dayscript test. The navigation guide covers typed route enums that make the keys compile-checked.

Styling: decoration vs. modifiers

Day’s modifier chain is closer to SwiftUI’s than to Flutter’s wrapper widgets, but the translation is direct:

FlutterDay
Container(
  padding: const EdgeInsets.all(16),
  decoration: BoxDecoration(
    color: const Color(0xFF1E293B),
    borderRadius: BorderRadius.circular(12),
  ),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text('Plan', style: Theme.of(context).textTheme.titleLarge),
      Text('Pro'),
    ],
  ),
)
column((
    label("Plan").font(Font::Title),
    label("Pro"),
))
.align(HAlign::Leading)
.padding(16.0)
.background(Color::hex(0x1E293B))
.corner_radius(12.0)

Each framework limits styling differently. Flutter can restyle anything because it draws everything. Day styles content and space (fonts, colors, padding, backgrounds, canvas drawing) and leaves control chrome (slider tracks, focus rings, checkbox shapes) to the platform on purpose. Styling lists what you can restyle and what stays native. If your design system needs every pixel identical on every platform, Flutter fights you less.

State management

Flutter needs Provider, Riverpod, or Bloc to scope rebuilds and to move state out of the widget tree; Day doesn’t, because there are no rebuilds. Signals live wherever you put them: module statics, structs, function locals. A derived value is a Memo; a side effect is an Effect; an expensive list keeps its identity through each.

let items = Signal::new(Vec::<Item>::new());
let total = Memo::new(move || items.with(|v| v.iter().map(|i| i.price).sum::<f64>()));

// Re-runs only when the sum actually changes:
label(move || format!("{:.2} €", total.get()))

Background work uses the same discipline as Dart isolates, with the compiler enforcing it: Signal is !Send, so a worker thread can’t touch one. You hand the thread a Setter (a Send, write-only handle that marshals back to the UI thread):

let progress = Signal::new(0.0);
let tick = progress.setter();          // Setter<f64>: Send + Copy

std::thread::spawn(move || {
    for step in 0..100 {
        // heavy work…
        tick.set(step as f64 / 100.0); // hops to the main thread
    }
});

For async, day::task runs a future whose continuations land on the UI thread, so completions are plain signal writes: the role a FutureBuilder plays, without the builder.

Package configuration

A Day app is a normal Cargo package plus a small app manifest. Day splits pubspec’s two roles into two files:

Flutter — pubspec.yamlDay
name: field_notes
version: 1.2.0+34

environment:
  sdk: ^3.5.0

dependencies:
  flutter:
    sdk: flutter
  go_router: ^14.0.0
  intl: ^0.19.0

flutter:
  uses-material-design: true
  assets:
    - assets/stations.json
    - images/
  fonts:
    - family: Pacifico
      fonts:
        - asset: fonts/Pacifico-Regular.ttf
# Cargo.toml — the package: name, version, dependencies
[package]
name = "field-notes"
version = "1.2.0"
edition = "2024"

[dependencies]
day = { git = "https://github.com/daybrite/day.git" }
day-part-http = { git = "https://github.com/daybrite/day.git" }

# Day.toml — the app: identity, targets, window
[app]
id = "dev.example.fieldnotes"
title = "Field Notes"
build = 34
targets = ["macos-appkit", "ios-uikit", "android-mdc"]

[window]
width = 480
height = 640

The Day side has no assets or fonts list: files under resource/ are found by convention, so adding an image never touches a manifest. Dependencies are ordinary Cargo dependencies; a package with native platform code (the equivalent of a plugin) declares its per-platform pieces in its own crate metadata, and day build folds them into the app’s Xcode and Gradle builds. The app never re-declares plugin wiring.

Resources and localization

Both frameworks generate typed accessors from your translation files. The formats differ: ARB plus intl on one side, Mozilla Fluent on the other. Fluent handles plural and gender grammar in the message itself rather than in code.

Flutter — ARB + intlDay
// lib/l10n/app_en.arb
// {
//   "unreadCount": "{count, plural, one {1 unread} other {{count} unread}}",
//   "@unreadCount": { "placeholders": { "count": {} } }
// }

Text(AppLocalizations.of(context)!.unreadCount(unread))
// resource/locales/en/app.ftl
// unread_count = { $count ->
//     [one] 1 unread
//    *[other] { $count } unread
// }

label(res::str::unread_count(unread))   // generated, compile-checked

res::str::unread_count is generated at build time from the .ftl files. Wrong key or wrong argument count is a compile error, and a $count used as a plural selector is typed numeric so you can’t pass a string. Adding a language is adding a resource/locales/<lang>/ directory; res::locales::install() registers whatever directories exist. The locale is a signal, so switching languages live re-renders every string. See Localization.

Images and data files follow the same by-convention model, and land in each platform’s native resource store (asset catalogs on iOS, res/drawable-* on Android, GResource on GTK) rather than a custom bundle format:

image("wave")                                  // resource/images/wave.png, wave@2x.png…
let data = day::resource("stations.json")?;    // resource/assets/, zero-copy bytes
label("Hi").font(Font::Custom("Pacifico", 24.0))  // resource/fonts/, by family name

Project structure and per-platform configuration

Flutter and Day use the same layout: thin per-platform host projects next to shared code.

FlutterDay
my_app/
├── pubspec.yaml
├── lib/
│   └── main.dart
├── assets/
├── android/          # Gradle host
├── ios/              # Xcode host
├── macos/ linux/ windows/ web/
└── build/
my-app/
├── Cargo.toml
├── Day.toml
├── src/
│   ├── lib.rs        # the app: pieces, signals, routes
│   └── main.rs       # desktop entry point
├── resource/         # assets/ images/ fonts/ icons/ locales/
├── dayscript/        # UI test flows
├── platform/
│   ├── ios/          # Xcode host (no app logic)
│   ├── android/      # Gradle host (no app logic)
│   └── ohos/         # HarmonyOS host
└── build/day/        # everything generated

The philosophy is the same; Day moves the configuration out: per-app settings live in Day.toml, so the scaffolds rarely change. Where Flutter uses flavors and per-platform folders for variant config, any Day.toml app property can be overridden per platform, per toolkit, or per target (most specific wins):

[app]
id = "dev.example.fieldnotes"
title = "Field Notes"

[app.ios]
title = "Field Notes Mobile"        # only iOS sees this

[app.macos-appkit]
id = "dev.example.fieldnotes.mac"   # only the macOS target sees this

Desktop targets need no host project at all: the Cargo binary is the app. And there’s a target Flutter doesn’t have: harmony-arkui for HarmonyOS, plus web-dom, which compiles the same crate to WebAssembly driving real DOM elements rather than a canvas.

Building and shipping

flutter build per platform maps to day pack, which builds release, signs with the platform’s own tools, and writes an installable artifact plus checksum to build/day/dist/:

TaskFlutterDay
run in devflutter runday launch -p macos-appkit
apply changeshot reload (r)day relaunch --all-running
Android store buildflutter build appbundleday pack -p android-mdc → signed .apk + .aab
iOS store buildflutter build ipaday pack -p ios-uikit.ipa via xcodebuild -exportArchive
macOSflutter build macos (then sign/notarize yourself)day pack -p macos-appkit → signed, notarized, stapled .dmg
Linuxflutter build linux (then package yourself)day pack -p linux-gtk → single-file .flatpak
Windowsflutter build windows (then package yourself)day pack -p windows-xaml.msix + NSIS installer
doctorflutter doctorday doctor

Day has no hot reload. A change is a Rust rebuild (incremental, a few seconds on a warm build), but not sub-second stateful patching, and losing that you feel on every edit. The replacement loop is day relaunch to apply changes, plus dayscript to script the app to the screen you’re iterating on instead of clicking back to it.

Signing config lives in Day.toml with secrets as ${ENV_VAR} references. A missing variable degrades that platform to a dev-signed artifact with a warning instead of failing the build. Packaging & distribution has the full per-target table.

Where to go next

  • Getting started — install, scaffold, first launch.
  • API tour — the whole authoring surface in one pass.
  • Reactivity — signals in depth; the no-rebuild model.
  • Why Day — the frank comparison, including when to keep Flutter.