Day for Flutter developers

Flutter draws its interface through a graphics engine; Day uses platform controls such as NSButton and Android Material buttons. This guide compares how the two frameworks handle UI composition, state, packages, resources, and builds, with examples in Dart and Rust. Why Day discusses the broader tradeoffs.

Flutter terms in Day

FlutterDay
Widget subclassa plain function returning a Piece
StatelessWidgetany function; Day has no widget class hierarchy
StatefulWidget + Statea function that creates Signals
setState(() { … })count.set(…) / count.update(…)
build() re-runs on changea binding re-runs and updates its widget
ValueNotifier / Provider / RiverpodSignal and Memo, built in
const constructors, keysnot needed — the tree is built once
Navigator / go_routernav, nav_stack, and string routes
MediaQuery.sizeOf + your own breakpointssize_class(), and a nav that re-presents itself
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, called over direct FFI
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() -> impl Piece {
    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)
}

The Day version keeps its state in a Signal inside a plain function, so there is no State class or BuildContext, and const optimization has no equivalent because component functions do not rebuild on state changes. Signal<T> is Copy, so it moves into both closures without a .clone(). The function runs once; after that, updates flow through the signal graph. Reactivity explains the signal graph.

Form controls: controllers vs. two-way signals

Flutter’s text input takes a TextEditingController you create, dispose, and listen to. Day’s input Pieces bind a signal directly: edits write the signal, and signal writes update the widget. Each write carries its origin, so the two directions don’t echo.

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),
    labeled("Subscribe", toggle(subscribed)),
))
.spacing(12.0)

Every signal and handler belongs to a scope, and when the page that created it goes away, the scope tears it all down, so there is nothing to dispose.

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. It is required, so reconciliation is always by identity.

go_router declares routes as configuration. Day models navigation as state you own: a nav projects a one-of-N signal onto a sidebar or tabs, and a nav_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.

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());

nav_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.

Adaptive layout

In Flutter you read MediaQuery.sizeOf, pick your own breakpoints, and swap NavigationRail for NavigationBar yourself. Day buckets the window into size classes (Android’s numbers, unchanged on every backend), and a nav re-presents itself when the window crosses one.

FlutterDay
final wide = MediaQuery.sizeOf(context).width >= 840;

return Scaffold(
  body: Row(children: [
    if (wide) NavigationRail(destinations: dests, selectedIndex: index),
    Expanded(child: page),
  ]),
  bottomNavigationBar:
      wide ? null : NavigationBar(destinations: dests, selectedIndex: index),
);
// One nav host, both presentations. Day re-resolves on every breakpoint
// crossing and re-homes the pages it already built, so scroll offsets and
// focus survive the morph rather than being rebuilt.
nav(page)
    .style(NavStyle::Sidebar)
    .destination(|key| page_for(key))

// Where the app wants to make the same call itself:
let two_up = size_class().is_some_and(|c| c.width >= WidthClass::Expanded);

The class is per window, so a second window at a different size (Stage Manager, Android split-screen, a resized desktop window) gets its own answer.

Styling: decoration vs. modifiers

Day’s modifier chain is closer to SwiftUI’s than to Flutter’s wrapper widgets:

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, so controls match the OS. Styling lists what you can restyle and what stays native. If your design system needs every pixel identical on every platform, Flutter is the better fit.

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; this covers the role a FutureBuilder plays.

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.12.0

dependencies:
  flutter:
    sdk: flutter
  go_router: ^14.0.0
  intl: ^0.20.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

Files under resource/ are found by convention, so the Day side has no assets or fonts list, and 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. Day can also embed your own SwiftUI views on the Apple targets (SwiftUI embedding).

Resources and localization

Both frameworks generate typed accessors from your translation files. Flutter uses ARB plus intl; Day uses Mozilla Fluent. 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):

image(res::images::wave)                       // resource/images/wave.png, wave@2x.png…
let data = day::resource(res::assets::stations_json)?;  // resource/assets/, zero-copy bytes
label("Hi").font(Font::custom(res::fonts::pacifico, 24.0))  // resource/fonts/, by family

Project structure and per-platform configuration

Flutter and Day use the same layout: 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

Day moves per-app settings into Day.toml, so the host 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

On desktop targets the Cargo binary is the app, with no host project. Day also targets harmony-arkui for HarmonyOS and web-dom, which compiles the same crate to WebAssembly driving DOM elements.

Maturity differs by target, and Platform support lists what each target has today: each (OS, toolkit) pair carries a support tier, from Tier 1 (thoroughly tested, shipping apps) down to Tier 4 (development combinations nobody ships).

Building and shipping

flutter build per platform maps to day pack, which builds release, signs with the platform’s 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 + .appimage
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) and a relaunch, and the app’s state does not survive it the way it does under hot reload. The replacement loop is day relaunch to apply changes, plus dayscript to script the app to the screen you’re iterating on so you don’t click 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 — examples of common UI components and patterns.
  • Reactivity — signals in depth; the no-rebuild model.
  • Why Day compares the approaches and says when to keep Flutter.