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
| Flutter | Day |
|---|---|
Widget subclass | a plain function returning a Piece |
StatelessWidget | any function; Day has no widget class hierarchy |
StatefulWidget + State | a function that creates Signals |
setState(() { … }) | count.set(…) / count.update(…) |
build() re-runs on change | a binding re-runs and updates its widget |
ValueNotifier / Provider / Riverpod | Signal and Memo, built in |
const constructors, keys | not needed — the tree is built once |
Navigator / go_router | nav, nav_stack, and string routes |
MediaQuery.sizeOf + your own breakpoints | size_class(), and a nav that re-presents itself |
pubspec.yaml | Cargo.toml (package) + Day.toml (app manifest) |
| assets section in pubspec | the resource/ directory, by convention |
ARB files + intl codegen | Fluent .ftl files + generated res::str functions |
| platform channels | parts, called over direct FFI |
flutter build apk / ipa | day pack -p android-mdc / -p ios-uikit |
| hot reload | none — 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.
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.
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.
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.
Navigation
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.
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.
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:
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:
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 = 640Files 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.
// 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-checkedres::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.
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 generatedDay 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/:
| Task | Flutter | Day |
|---|---|---|
| run in dev | flutter run | day launch -p macos-appkit |
| apply changes | hot reload (r) | day relaunch --all-running |
| Android store build | flutter build appbundle | day pack -p android-mdc → signed .apk + .aab |
| iOS store build | flutter build ipa | day pack -p ios-uikit → .ipa via xcodebuild -exportArchive |
| macOS | flutter build macos (then sign/notarize yourself) | day pack -p macos-appkit → signed, notarized, stapled .dmg |
| Linux | flutter build linux (then package yourself) | day pack -p linux-gtk → single-file .flatpak + .appimage |
| Windows | flutter build windows (then package yourself) | day pack -p windows-xaml → .msix + NSIS installer |
| doctor | flutter doctor | day 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.