Activity
Flutter application state — typed controllers, nested navigation, ICU messages.
Start learning · Complete example · Nested routes · Shells and tabs · Restoration · ICU messages · Runnable app · Test map · Compatibility notes · Publishing checklist
| Build with | Use it for |
|---|---|
| Controllers and reactive values | Typed application state, shared logic and asynchronous task tracking |
| ActiveView, Activity and selectors | Whole-view updates, inherited controller access and targeted rebuilds |
| Nested routes and shell layouts | Deep links, shared navigation chrome and independent tab histories |
| Restorable navigation and async guards | Restoring destinations, checking sessions and confirming route exits |
| Configurable localization and ICU messages | Asset/existing-project translations, language fallback, plural and gender selection |
| Validation, ENV and typed helpers | Form checks, configuration parsing and observable value transformations |
Explore the Material feature gallery: 19 searchable sections with live previews, copyable source, state helpers, events, async work, nested routing, restoration, four ICU locales and a practical to-do/do-not-do checklist. Includes responsive navigation, theme/accent settings and a live console.
Activity is a Flutter library for controller-owned state and application plumbing.
Routing is implemented directly with Flutter's Router, Navigator and PopScope:
nested paths, retained tabs, typed results, asynchronous guards and restoration
are Activity APIs. intl supplies locale cardinal rules and number formatting.
The declared minimum remains Dart 3.7.2 / Flutter 3.29.0. This guide describes
2.0.0; follow the release checklist before uploading it.
intl >=0.19.0 <0.21.0 accommodates Flutter's localization SDK pins.
Read the routing cookbook for complete setup, deep links, transitions, guards and integration boundaries, and the feature recipes for controller, async, event and ICU workflows.
See Observations.md for implementation status and validation limits. Navigation restoration does not persist controller models, and client configuration does not provide server-side authorization or secret storage.
Feature examples and API comments
Public feature comments include 2–3 complete Dart examples with imports, controller ownership and disposal, and links to the corresponding gallery walkthroughs. Browse them in editor hovers or generated Dartdoc; the snippet index explains browser requirements and how all 57 marked examples are checked.
| Feature | Example walkthrough | API source |
|---|---|---|
| Scalars, nullable values and helpers | Reactive values | Typed values |
| Lists, maps and immutable models | Collections, models | List, map |
| Scopes, views and selectors | Rebuild behavior | Selector |
| Async work, errors and cleanup | Tasks and recovery | Controller |
| Events and subscriptions | Events and replay | Deamon |
| Routes and typed results | Navigation | Router |
| Locale loading, interpolation and ICU | Localization | Delegate, formatter |
| Forms and configuration | Validation, ENV | Validator, ENV |
Open the example guide to run these examples and inspect live results beside copyable source. The gallery uses the supplied Activity SVG and its blue palette.


Try the illustrated feature · Phone preview · Run the gallery
Gallery recipes and integration templates
The gallery guide maps every section to its compiled source. Each example separates API fragments, complete view code, model/controller code, and application setup. Local result terminals show status, structured results, timing and copy controls. The docked developer console is opt-in and disabled in release builds.
Start with the form/search/pagination workbench and its controller: validation preserves a draft, filtered/sorted results are derived, asynchronous loads support retry, and generation tokens reject stale responses after newer work or cancellation. This cancels result application; it does not cancel I/O. The existing events, guarded routes, shell layout, localization and restoration sections supply the related application patterns.
The QuickeyDB recipe and
complete model/DAO/controller
use real SQLite with explicit conflict handling and atomic rollback. The app owns
the database and controller, views borrow them, failures retain drafts, and record
details return a typed RecordResult. Database operations drain before closure.
Its records are plaintext and session-only. QuickeyDB remains an example-only
local dependency; Activity's library dependencies and release version are unchanged.
Latest published Activity checked on 20 October 2026: 1.5.3+5; QuickeyDB: 1.2.0+8. This gallery requires the local Activity 2.0.0 and QuickeyDB 3.0.0 checkouts. See upgrade and platform notes and the public API coverage checklist before adapting these examples to a published-version application.
New to Activity?
A controller owns application logic. A reactive value wraps a Dart value and notifies its controller when changed. A view listens and rebuilds; a selector rebuilds only when its selected immutable value changes.
Controller → registered values → change notifications → view / scope / selector
↓
router refresh → async guards → destination
- Create final reactive fields and list them in
activities. Base construction attaches them automatically; initialize them before the constructor body. - Keep one owner for the controller. Pass that instance to a view or an Activity
scope; descendants read it with
Activity.getActivity<MyController>(context). - Update through
set, call syntax or typed helpers. Values change immediately; notification delivery and widget rebuilds happen asynchronously. - Use
ActivitySelectorfor small subtrees. Select immutable scalars/records, rather than mutating a selected list in place. - Own routers and localization delegates once. Dispose the controller/router when their application owner finishes; widgets cancel their own listeners.
Wrappers use identity equality; compare values explicitly with .equals(...) or
.value. Collection snapshots are shallow. Controller task bookkeeping keeps
busy state correct but does not cancel network requests or persist models.
Standalone pub.dev example
activity_example.dart is a complete counter app
using only Activity and Flutter. Copy it into an application's lib/main.dart;
use activity: ^2.0.0 once this release is published. For local verification,
point the dependency at this checkout. It creates one controller, selects its
count and disposes the controller from its owner.
The full gallery remains a workspace application. Its QuickeyDB 3.0.0 path
dependency is not a dependency of the Activity library or standalone example;
clone both sibling repositories to run the database gallery recipe. The gallery's
workspace-only pubspec is excluded from the uploaded package. In an Activity-only
repository checkout, run flutter pub get --no-example for package checks.
Run the example in Chrome
Run from the package's example/ directory:
cd plugins/external/Activity/example
flutter pub get
flutter run -d chrome
The app uses the same public barrel as consumers. The Flutter
flutter_localizations SDK delegates localize built-in controls alongside
Activity translations. No translation generation or web setup script is needed.
Setup and a complete example
Add a local dependency (adjust the path), then run flutter pub get:
dependencies:
flutter:
sdk: flutter
activity:
path: ../Activity
Use a separately verified published version when installing from pub.dev; a checkout version alone does not establish release availability.
Place this in your application's lib/main.dart. The root owns one controller
and passes the same instance to the view. An Activity wrapper is optional when
the view already uses ActiveState.
import 'dart:async';
import 'package:activity/activity.dart';
import 'package:flutter/material.dart';
void main() => runApp(const CounterApp());
class CounterController extends ActiveController {
final count = ActiveInt(0, typeName: 'count');
@override
Iterable<ActiveType> get activities => [count];
void increment() => count.set(count.value + 1);
}
class CounterApp extends StatefulWidget {
const CounterApp({super.key});
@override
State<CounterApp> createState() => _CounterAppState();
}
class _CounterAppState extends State<CounterApp> {
final controller = CounterController();
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CounterPage(activeController: controller),
);
}
@override
void dispose() {
unawaited(controller.resetActivities());
super.dispose();
}
}
class CounterPage extends ActiveView<CounterController> {
const CounterPage({super.key, required super.activeController});
@override
ActiveState<CounterPage, CounterController> createActivity() =>
_CounterPageState(activeController);
}
class _CounterPageState extends ActiveState<CounterPage, CounterController> {
_CounterPageState(super.activeController);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Activity counter')),
body: Center(child: Text('${activeController.count.value}')),
floatingActionButton: FloatingActionButton(
onPressed: activeController.increment,
child: const Icon(Icons.add),
),
);
}
}
createActivity() is the view extension point; do not override createState()
as well. ActiveViewLegacy<T> also exists for older view hierarchies. Prefer
ActiveView<T> for new code.
Sharing a controller with Activity
Use Activity<T> to expose an existing controller to descendants. The callback
onActivityStateChanged is optional; by default an internal revision counter
invalidates dependents for each notification. A custom callback must return a
different string for each notification.
This alternative root uses CounterController from the counter example:
class SharedCounterRoot extends StatefulWidget {
const SharedCounterRoot({super.key});
@override
State<SharedCounterRoot> createState() => _SharedCounterRootState();
}
class _SharedCounterRootState extends State<SharedCounterRoot> {
final controller = CounterController();
int revision = 0;
@override
Widget build(BuildContext context) {
return Activity<CounterController>(
controller,
onActivityStateChanged: () => '${++revision}',
child: const MaterialApp(home: SharedCounterPage()),
);
}
@override
void dispose() {
unawaited(controller.resetActivities());
super.dispose();
}
}
class SharedCounterPage extends StatelessWidget {
const SharedCounterPage({super.key});
@override
Widget build(BuildContext context) {
final controller = Activity.getActivity<CounterController>(context);
return Scaffold(
body: Center(child: Text('${controller.count.value}')),
floatingActionButton: FloatingActionButton(
onPressed: controller.increment,
child: const Icon(Icons.add),
),
);
}
}
Read the scope from a descendant's build or didChangeDependencies, using the
same controller type as the wrapper. getActivity establishes an inherited
widget dependency. Scopes and views rebind their subscriptions when the controller changes under
the same widget key. One owner must dispose the old controller when it is unused.
Reactive values and collections
| Value | API examples |
|---|---|
| General value | ActiveType<T>(value), createActiveType(value) |
| Nullable value | createActiveNullableType<T>() |
| Boolean | ActiveBool(false), ActiveNullableBool() |
| Integer | ActiveInt(0), ActiveNullableInt() |
| Double | ActiveDouble(0.0), ActivityNullableDoubleProperty() |
| String | ActiveString(''), ActiveNullableString() |
| Date/time | ActiveDateTime(DateTime.now()) |
| Collection | ActiveList<T>([]), ActiveMap<K, V>({}) |
For example, register these fields in a controller:
class ProfileController extends ActiveController {
final name = ActiveString('Kenzy', typeName: 'name');
final age = ActiveInt(20, typeName: 'age');
final tags = ActiveList<String>([], typeName: 'tags');
final scores = ActiveMap<String, int>({}, typeName: 'scores');
@override
Iterable<ActiveType> get activities => [name, age, tags, scores];
void updateProfile() {
name('Grace'); // Callable shorthand for set().
age.set(21); // set() returns the updated value.
tags.add('flutter'); // Collection helpers notify listeners.
scores.addEntry(const MapEntry('completed', 1));
}
}
A changed scalar emits when oldValue != newValue. notifyChange: false
suppresses that notification. A standalone field must be attached to a controller
before an operation that notifies; otherwise it throws
ActiveTypeNotAssignedException.
Mutating tags.value or scores.value directly bypasses notifications. Prefer
wrapper methods or replace the entire value with a fresh collection. Collections
and model reset values are not deep snapshots; use immutable nested objects.
Wrappers use identity equality and stable identity hashes. Compare values with
field.equals(other) or field.value == expected. List/map snapshots are shallow.
Batch updates and resets
Using the ProfileController above:
final profile = ProfileController();
profile.activateTypes([
{profile.name: 'Grace'},
{profile.age: 30},
]);
profile.name.set('Saved name', setAsOriginal: true);
profile.name('Unsaved name');
profile.name.reset(); // Restores 'Saved name'.
profile.resetAllActiveTypes(); // Resets all registered values in one batch.
Each batch entry must contain exactly one field/value pair. A batch validates all dynamic assignments before applying them and emits one
list of events. Prefer activate with typed changeTo assignments (below).
Custom subclasses overriding setters must validate their own invariants; these
APIs do not roll back arbitrary user-defined setter side effects.
resetAllActiveTypes() restores values. dispose() (also available as resetActivities()) permanently closes
the controller's streams; it is cleanup, not a way to reset a form for reuse.
Async tasks and errors
activeAsync marks a named task as running, awaits the operation, and clears the
key in finally. It returns the task result and propagates exceptions.
class SearchController extends ActiveController {
final results = ActiveList<String>([]);
final message = ActiveString('');
@override
Iterable<ActiveType> get activities => [results, message];
Future<void> search(Future<List<String>> Function() fetch) async {
if (isTaskRunning(isRunningKey: 'search')) return;
try {
final items = await activeAsync(fetch, isRunningKey: 'search');
if (isDisposed) return;
results.set(List<String>.from(items));
message('');
} on Exception catch (error) {
message('Search failed: $error');
}
}
}
An ActiveState can display
ifRunning(const CircularProgressIndicator(), otherwise: content) or inspect
activeController.isTaskRunning(isRunningKey: 'search'). Overlapping operations, including calls with the same key, are reference counted.
activeTasks is an unmodifiable snapshot of distinct keys. Disposing prevents new
work immediately. Pending tasks continue and preserve their result or original
exception; final busy notifications are skipped after disposal. Your async code
should check isDisposed after awaiting work before writing controller values.
activeAsync does not automatically emit ErrorEvents. Controllers expose
onActiveError and onActiveErrorListener for explicit error reporting. Custom
listeners return subscriptions; release them with
await controller.cancelSubscription(subscription) when their owner finishes.
Models
ActiveModel<T> extends ActiveType<T> and can appear in activities. It
supports the same typed updates, resets, selection, and automatic registration:
class Person {
const Person(this.name);
final String name;
}
class PersonController extends ActiveController {
final person = ActiveModel<Person>(const Person('Kenzy'));
@override
Iterable<ActiveType> get activities => [person];
void rename(String name) => person(Person(name));
}
Replace objects rather than mutating them in place so comparisons and resets have meaningful values.
Validation
SchemaValidator checks a map synchronously. Results contain valid, data
(a shallow copy of the input), schema (input with invalid or absent optional
fields removed), and errors (the first message for each failing field).
final validator = SchemaValidator({
'email': {'type': String, 'required': true, 'email': true},
'age': {'type': int, 'min': 18},
'password': {'type': String, 'required': true, 'minLength': 8},
'confirmation': {'required': true, 'equalsTo': 'password'},
}, customErrors: {
'email.required': 'Enter your email address.',
'age.min': 'You must be at least 18.',
});
final result = validator.validate({
'email': 'itskenzylimon@gmail.com',
'age': 28,
'password': 'example-password',
'confirmation': 'example-password',
});
if (result.valid) {
// Use result.schema as input for your next application step.
} else {
printError(result.errors.toString());
}
Additional rules include notEmpty, requiredIf, requiredWhen, max,
maxLength, minItems, maxItems, pattern, in, date, before, after,
url, phone, uuid, ipv4, and ipv6. Custom error precedence is
field.rule, then field, then rule, then the built-in message.
Custom rules return String?: return null or an empty string for success and an
error message for failure. Unknown keys are retained by default; pass
allowUnknownKeys: false to keep only declared, valid fields in schema.
The response maps are unmodifiable shallow copies. URLs require HTTP(S) and a
host. Dates require an ISO date prefix with a valid calendar day. IP checks are
syntactic checks; they do not establish address reachability.
final usernames = SchemaValidator({
'username': {
'type': String,
'custom': (dynamic value, Map<String, dynamic> data) =>
value == 'admin' ? 'Reserved username' : null,
},
'code': {'pattern': r'^ABC\d+$'},
}, allowUnknownKeys: false);
final checked = usernames.validate({'username': 'ada', 'code': 'ABC123', 'extra': 1});
// checked.schema contains username and code; extra is omitted.
Environment configuration
ENVSetup loads strings and provides typed getters. For local configuration:
final env = ENVSetup();
env.merge({
'API_URL': 'https://example.com',
'RETRIES': '3',
'ENABLED': 'true',
'TIMEOUT': '30s',
});
final apiUrl = env.getUri('API_URL');
final retries = env.getInt('RETRIES');
final enabled = env.getBool('ENABLED');
final timeout = env.getDuration('TIMEOUT');
printInfo('$apiUrl; retries=$retries; enabled=$enabled; timeout=$timeout');
await env.loadFile('/absolute/path/.env') loads a native file.
await env.loadAsset('assets/.env') loads a declared Flutter asset after binding
initialization. Getters also support defaults, for example
env.getInt('RETRIES', defaultValue: 3).
Values bundled with an app are readable by its users; keep production secrets
on a server. Native file access and process environment are isolated with conditional imports.
Web apps can use loadAsset, loadString, and merge; loadFile logs a warning
and does nothing on web. A URI getter accepts relative URIs; enforce a scheme and
host in application code when you need an absolute endpoint.
You can also parse text directly, which is convenient for existing configuration services and tests. Parsing completes before merging, so malformed quoted input leaves existing values intact:
final textEnv = ENVSetup();
textEnv.loadString('''
HOST="example.com" # inline comments may follow quotes
API_URL=https://\${HOST}/api
TIMEOUT=30s
''');
final endpoint = textEnv.getUri('API_URL');
final requestTimeout = textEnv.getDuration('TIMEOUT');
Expansion supports ${NAME} and ${NAME:-default}; unresolved references become
empty strings unless a default is supplied. Native process environment is a
secondary lookup source, while browsers only use supplied values. Cycles stop
expanding rather than recursing indefinitely. Single quotes preserve escape
characters; double quotes interpret common escapes. expand: false leaves
references intact. Default getters apply to missing keys; invalid present values
still throw FormatException.
Other utilities
- Strings: exported extensions include
capitalize(),title(),toCamelCase(),containsAll(...), andcontainsAny(...). - Logging:
printInfo,printWarning,printError, andprintSuccessuse the configurableLogger.instance. Release logging defaults to off. SetLogger.instance.printInRelease = trueto opt in. - Localization:
ActiveLocalization.delegateloadsassets/language/<languageCode>.json. Register the delegate and supported locales in your app and declare those asset files.getTranslated(context, key)can return null for missing keys. The default supported language codes areenandsw; Flutter's Material localization delegates are configured separately. - Navigation:
Nav.to,pushNamed, andoffwrap Flutter navigation.offAllclears the whole stack;replacereplaces the top route.pushUntilaccepts a real FlutterRoutePredicate. Results are typed. - Event bus:
Deamonis exported byactivity.dart; its direct import also remains supported.
import 'package:activity/core/events/deamon.dart';
Future<void> eventExample() async {
final bus = Deamon();
final nextMessage = bus.next<String>(); // Subscribe before emitting.
bus.emit('Ready');
final message = await nextMessage;
printInfo(message);
await bus.close();
}
on<T>() filters events by type; emitSticky<T>() caches a value for
onSticky<T>(). Every subscriber gets the latest cached value at subscription time, then live
events in order. Sticky cache lookup uses the exact generic type passed to
emitSticky<T>; live filtering includes subtypes. Use emitSticky<Base>(child)
if base-type subscribers should receive cached children. Each bus is local to
its Dart isolate; communicate between isolates explicitly. Injected controllers
must be broadcast and are owned/closed by the bus. Cancel subscriptions when
their consumers finish; close clears the cache and closes the source.
Typed updates and helper reference
Import package:activity/activity.dart for every API below. Fields that notify
must belong to a live controller. To experiment with standalone values, call
set(..., notifyChange: false) or explicitly register them with controller.register.
Registration attaches a late-created field; include it in activities too if it
should participate in resetAllActiveTypes.
final profile = ProfileController();
profile.activate([
profile.name.changeTo('Grace'), // Requires String at compile time.
profile.age.changeTo(30), // Requires int at compile time.
]);
profile.age.updateValue((age) => age + 2);
profile.name.setIfChanged('Grace'); // Returns false and emits nothing.
final label = profile.age.select((age) => 'Age: $age');
profile.tags.replaceAll(['flutter', 'dart']);
profile.tags.addIfAbsent('dart'); // false, no duplicate.
profile.tags.sort();
final grouped = profile.tags.groupBy((tag) => tag.length);
final first = profile.tags.firstOrNull;
final page = profile.tags.paginate(pageNumber: 20, pageSize: 10); // []
profile.scores.putIfAbsent('completed', () => 0);
profile.scores.setEntry('completed', 2);
final frozenScores = profile.scores.snapshotValue;
updateValue gives collection transforms a shallow copy, then emits one event
when committing the returned collection. Throwing inside the transform leaves
the original collection unchanged unless the callback mutates shared nested
objects. setAsOriginal, setToOriginal, and setOriginalValueToCurrent() copy
list/map originals. Every reset copies again. Override snapshot for a custom
copy policy; immutable models usually need no override.
| Type | Read helpers | Mutating helpers |
|---|---|---|
Any ActiveType<T> |
value, originalValue, equals, select, isNull |
set, call syntax, updateValue, setIfChanged, reset, changeTo |
ActiveBool |
isTrue, isFalse |
setTrue, setFalse, toggle |
| Numeric types | isPositive, isNegative, isZero, isBetween, asInt, asDouble |
clampTo, integer/decimal increment and decrement |
ActiveInt / ActiveDouble |
add, subtract, multiply, divide, mod calculate without mutation |
set, updateValue |
ActiveType<String> |
isBlank, tryInt, tryDouble |
trimValue, append, prepend, replaceText |
| Nullable values | valueOr |
setIfNull lazily initializes only missing values |
| Lists | firstOrNull, lastOrNull, where, firstWhereOrNull, any, every, fold, groupBy, paginate, snapshotValue |
add, addAll, insert, insertAll, insertAllAtEnd, remove, removeAt, clear, replaceAt, replaceAll, sort, retainWhere, removeWhere, addIfAbsent |
| Maps | containsKey, containsValue, entries, keys, values, valueOr, mapEntries, snapshotValue |
addAll, addEntry, addEntries, update, updateAll, remove, removeWhere, clear, putIfAbsent, setEntry |
| Date/time | startOfDay, isSameDay |
addDuration, subtractDuration |
Existing ActiveInt.increment and decrement increment by one and accept the
notifyChange flag. To add an arbitrary amount use updateValue((n) => n + 5).
startOfDay preserves local/UTC construction; isSameDay compares calendar
components as represented, without converting time zones. Duration arithmetic
uses elapsed time and can cross local daylight saving boundaries.
A collection's .value remains mutable for compatibility. Direct mutation bypasses
notifications and disposal guards. Use snapshotValue for a read-only shallow
snapshot. Events for insertion/removal contain the affected elements; replacement
and reset events contain old/new collection snapshots. All snapshots remain
shallow: nested models need immutable fields or application-specific copies.
Selective rebuilding
Use a selector for a small subtree. The selector runs on controller notifications;
its builder runs when the selection changes under ==. Selecting a Dart record
is useful when a widget depends on several scalar fields:
ActivitySelector<ProfileController, (String, int)>(
controller: profile,
select: (controller) => (controller.name.value, controller.age.value),
child: const Icon(Icons.person),
builder: (context, selected, child) => Row(children: [
child!,
Text('${selected.$1}, ${selected.$2}'),
]),
);
Keep selectors free of side effects. Select immutable values or records of
scalars. A mutable collection selected by reference will compare equal to itself
after in-place changes. ActivitySelector rebinds on controller replacement and
cancels its listener on disposal. Its optional child can remain independent of
selected state.
Localization integration
Create one application-owned delegate. No global language list needs editing. Add it alongside your existing delegates and pass its locale list to your app:
import 'package:activity/activity.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
final translations = ActiveLocalizationDelegate(
supportedLocales: const [Locale('en'), Locale('sw')],
fallbackLocale: const Locale('en'),
assetPath: 'assets/i18n',
);
MaterialApp(
supportedLocales: translations.supportedLocales,
localizationsDelegates: [
translations,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: const Scaffold(body: Text('Home')),
);
For a runnable
example with Flutter SDK localization delegates, see
example/lib/gallery/app.dart and example/assets/i18n/.
For Flutter's built-in controls in languages other than English, add Flutter's
flutter_localizations SDK dependency and its global delegates. Activity does
not replace Material/Widgets/Cupertino translations.
Declare the Flutter SDK localization dependency and translation directory in the
consuming app's pubspec.yaml:
dependencies:
flutter_localizations:
sdk: flutter
flutter:
assets:
- assets/i18n/
assets/i18n/en.json:
{"welcome": "Hello {name}", "save": "Save"}
assets/i18n/sw.json:
{"welcome": "Habari {name}"}
Text(context.tr('welcome', parameters: {'name': 'Kenzy'}));
Text(context.tr('save')); // Uses English fallback if the active locale is sw.
Text(context.tr('unknown', fallback: 'Default text'));
Files use BCP 47 locale tags: en.json, en-GB.json, zh-Hant.json, etc.
The delegate uses exact supported locale matching; Flutter resolves the requested
locale against the list. Configure fallbackLocale explicitly for missing keys
and missing primary assets. Malformed JSON, non-string translation values, and
missing fallback assets remain visible errors. The fallback is loaded before the
primary locale. Unresolved keys return the key (or the supplied fallback).
Interpolation replaces {name} once; inserted text is never recursively expanded.
ICU cardinal plural and select syntax is described below.
To integrate an existing translation repository or generated map, supply a loader:
final embeddedTranslations = ActiveLocalizationDelegate(
supportedLocales: const [Locale('en')],
loader: (locale) async => {'welcome': 'Hello {name}'},
);
The loader receives the full Locale; its errors propagate. It should implement
its own missing-resource policy. An injected AssetBundle supports existing asset
systems and isolated tests. ActiveLocalization.delegate and getTranslated
remain available for legacy language-code assets; new code should use the
configurable delegate and context.tr.
ICU pluralization and select messages
Translation values can be ICU MessageFormat 1 patterns. Pass numbers as numbers;
Activity selects locale cardinal categories through intl, then formats # in
that locale. Exact =number branches take precedence over language categories.
assets/i18n/en.json:
{
"tasks": "{count, plural, =0{No tasks} one{# task} other{# tasks}}",
"guests": "{count, plural, offset:1 =0{Nobody} =1{Just you} one{You and # guest} other{You and # guests}}",
"owner": "{gender, select, female{Her tasks} male{His tasks} other{Their tasks}}",
"summary": "{gender, select, female{{count, plural, one{She has # task} other{She has # tasks}}} other{{count, plural, one{They have # task} other{They have # tasks}}}}"
}
Text(context.tr('tasks', parameters: {'count': 3})); // 3 tasks
Text(context.tr('guests', parameters: {'count': 3})); // You and 2 guests
Text(context.tr('owner', parameters: {'gender': 'female'})); // Her tasks
Text(context.tr('summary', parameters: {'gender': 'female', 'count': 2}));
| Pattern | Contract |
|---|---|
{name} |
String interpolation; a missing simple argument remains {name} |
{count, plural, ...} |
Requires a finite num and an other branch |
zero, one, two, few, many, other |
Locale categories, not fixed numeric comparisons |
=0, =1, =1.5 |
Exact numeric matches before applying an offset |
offset:1 |
Subtracts one for category selection and #, not for exact matches |
{gender, select, ...} |
Matches a string value; unknown values use the required other branch |
| Nested plural/select | Inner plurals own their #; surrounding # retains the outer value |
| Apostrophe quoting | '{' prints a brace; '#' prints a literal pound inside a plural; '' prints an apostrophe |
For example, French 0 uses the one category, Russian 2 uses few, and Arabic
0/2 use zero/two. An English message's zero{...} is not an exact zero test;
use =0{...} for that behavior. Numeric inputs do not preserve source precision
such as the difference between textual 1 and 1.00.
Parameters are inserted as literal text and are never parsed as another message.
Ordinary apostrophes in words such as Don't remain literal. A malformed pattern,
duplicate branch, missing other or unsupported format raises FormatException.
Missing/non-numeric complex arguments raise ArgumentError. Patterns are parsed
once per localization instance/template and then reused. Nesting is bounded to
64 levels. A fallback translation uses the fallback language's plural rules.
For formatter use outside a widget:
final message = IcuMessage('{count, plural, one{# file} other{# files}}');
final text = message.format(locale: 'en', parameters: {'count': 2}); // 2 files
The supported scope is MessageFormat 1 placeholders, cardinal plurals and
select. selectordinal, date/time/number skeleton arguments, rich text and
MessageFormat 2 are explicitly unsupported; format those values with an
application formatter before interpolating them. There is no generated ARB code
or build_runner step.
Nested routes and named navigation
ActivityRouter owns a Flutter RouterDelegate and RouteInformationParser.
Use Activity route types throughout the tree. Its native Flutter APIs are exposed
as routerDelegate, routeInformationParser, routeInformationProvider and
config; context.activityRouter and context.activityRoute provide scoped access.
final router = ActivityRouter(
initialLocation: '/teams/7',
routes: [
ActivityRoute(
path: '/teams/:team', name: 'team',
builder: (_, state) => Scaffold(body: Text('Team ${state.pathParameters['team']}')),
routes: [
ActivityRoute(
path: 'members/:member', name: 'member',
builder: (_, state) => Scaffold(
appBar: AppBar(title: const Text('Member')),
body: Text('Team ${state.pathParameters['team']}, '
'member ${state.pathParameters['member']}'),
),
),
],
),
],
);
MaterialApp.router(routerConfig: router.config);
router.goNamed('member', pathParameters: {'team': '7', 'member': '9'},
queryParameters: {'tab': 'posts'});
Top-level paths start with /; child paths are relative. A direct link to
/teams/7/members/9 builds both pages, so Back reveals team 7. Child builders
receive inherited path parameters. Register static paths before overlapping
parameter paths. Route names must be globally unique.
| Action | Behavior |
|---|---|
go(location) / goNamed(name, ...) |
Replaces the matched hierarchy for the destination |
push<T>(location) / pushNamed<T>(name, ...) |
Adds a page and returns a Future<T?> completed when it is popped |
replace<T>(location) |
Replaces the top page and completes its previous result with null |
pop<T>(result) |
Returns false at the root, otherwise requests a pop and supplies the result |
canPop |
Checks the active declarative page stack (dialogs remain Flutter routes) |
namedLocation(...) |
Encodes full nested paths, repeated query values and optional fragments |
ActivityRouter.of(context) |
Retrieves the Activity owner from a route widget |
ActivityRouteState.of(context) |
Reads the current route's parameters, query, extra and page key |
router.routerDelegate / routeInformationParser |
Exposes Flutter Router integration without an external routing package |
final selected = await router.pushNamed<int>('member',
pathParameters: {'team': '7', 'member': '9'});
// Inside that page: ActivityRouter.of(context).pop<int>(42);
ActivityRouteState exposes uri, pathParameters, queryParameters,
queryParametersAll, name, fullPath, extra, pageKey, and error.
Use pageBuilder for custom transitions or Cupertino pages. When supplying a
custom page, provide a stable restorationId if that page needs restoration.
Unknown routes and guard errors reach errorBuilder; guard errors are reported
asynchronously through the mounted Router.
Shell layouts and independent tabs
A shell keeps shared UI outside its nested Navigator:
final rootKey = GlobalKey<NavigatorState>();
final shellRouter = ActivityRouter(navigatorKey: rootKey, initialLocation: '/items', routes: [
ActivityShellRoute(
restorationScopeId: 'items_shell',
builder: (_, state, child) => Scaffold(
appBar: AppBar(title: const Text('Workspace')),
body: child,
),
routes: [
ActivityRoute(path: '/items', builder: (_, state) => const Text('Items'), routes: [
ActivityRoute(
path: 'preview', parentNavigatorKey: rootKey,
builder: (_, state) => const Scaffold(body: Text('Full-screen preview')),
),
]),
],
),
]);
The preview targets the root Navigator, above the shared shell. Without
parentNavigatorKey, it stays inside the shell. Nested shells can each own their
own Navigator key and layout.
Use an indexed stateful shell when tabs need independent navigation histories:
final tabs = ActivityRouter(initialLocation: '/feed', restorationScopeId: 'router', routes: [
ActivityStatefulShellRoute.indexedStack(
restorationScopeId: 'main_tabs',
branches: [
ActivityShellBranch(restorationScopeId: 'feed_branch', routes: [
ActivityRoute(path: '/feed', builder: (_, state) => const Text('Feed'), routes: [
ActivityRoute(path: 'detail/:id', builder: (_, state) => Text('Post ${state.pathParameters['id']}')),
]),
]),
ActivityShellBranch(restorationScopeId: 'settings_branch', routes: [
ActivityRoute(path: '/settings', builder: (_, state) => const Text('Settings')),
]),
],
builder: (_, state, shell) => Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: (index) => shell.goBranch(index),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
],
),
),
),
]);
The builder receives an ActivityNavigationShell: put it in the widget tree and
use currentIndex/goBranch. Returning to a tab restores its last route; pass
initialLocation: true to reset that tab to its initial location. Widget state
in other tabs stays mounted in the IndexedStack. Each visited branch retains its
page stack and pending result futures. Restored routes run guards again; a future
awaited before process death cannot survive into the restarted process.
State restoration and browser URLs
Enable restoration at the application, router, shell and branch levels:
MaterialApp.router(
restorationScopeId: 'my_app',
routerConfig: tabs.config, // The tabs configuration above already has scope IDs.
);
| State | How it survives |
|---|---|
| Nested destination and selected tab | Router serializes the complete match configuration |
| Visited tab histories | Activity registers a restorable history for each branch |
| Form fields, scroll offsets and other widget state | Use Flutter RestorationMixin and RestorableProperty APIs with stable IDs |
JSON-compatible extra |
Encoded by the navigation engine |
Custom objects in extra |
Supply a matching extraCodec: Codec<Object?, Object?> to ActivityRouter |
| Controller values and network data | Application persistence/reloading; routing does not persist controllers |
Keep scope IDs unique and stable between launches, and own each router outside
build. Activity shell wrappers supply stable restorable MaterialPages. Custom page builders need stable page keys and restoration IDs. Use
MaterialPage, ActivityTransitionPage or ActivityNoTransitionPage; these
page types participate in route-state scoping and asynchronous exit guards.
URL path/query values remain the portable source for reloadable destinations.
A platform deep link takes precedence over initialLocation; set
overridePlatformDefaultLocation: true when explicitly overriding it is intended.
Browser Back/Forward goes through the normal parser and guards. When using path
URL strategy, configure the host to serve the app for deep-link paths. Browser
reload and OS state restoration have different lifecycles; ordinary controller
fields do not automatically survive either.
Regression tests restart with a new router instance, restore the active tab, return to an inactive tab's nested route, and verify RestorableInt values. This is stronger than retaining the same router instance across a widget rebuild.
Async guards and Activity integration
Top-level, page and shell redirects accept FutureOr<String?>. Return null to
allow a destination or a location to redirect. Async work completes before the
protected page builder runs; the current page remains visible while a navigation
guard is pending.
class SessionController extends ActiveController {
final signedIn = ActiveBool(false);
@override
Iterable<ActiveType> get activities => [signedIn];
Future<bool> hasSession() async => signedIn.value; // Replace with your session service.
}
final session = SessionController();
final guarded = ActivityRouter(
refreshController: session,
routes: [
ActivityRoute(path: '/', builder: (_, state) => const Scaffold(body: Text('Home'))),
ActivityRoute(path: '/login', builder: (_, state) => const Scaffold(body: Text('Sign in'))),
ActivityRoute(
path: '/account',
redirect: (state) async => await session.hasSession() ? null : '/login',
builder: (_, state) => const Scaffold(body: Text('Account')),
),
],
errorBuilder: (_, state) => Scaffold(body: Text('Navigation failed: ${state.error}')),
);
Activity(session, child: MaterialApp.router(routerConfig: guarded.config));
Controller notifications trigger re-evaluation, including after sign-out. A newer navigation supersedes a pending parse, so an old guard cannot later send the user to its destination. Redirect cycles and excessive redirects reach the error builder. The guard adapter forwards async exceptions to that error page rather than leaving an unresolved parse across the engine's error-zone boundary.
Use ActivityRoute.onExit for asynchronous leave confirmation:
ActivityRoute(
path: '/edit',
builder: (_, state) => const Scaffold(body: Text('Editor')),
onExit: (context, state) async {
final leave = await showDialog<bool>(context: context, builder: (dialogContext) => AlertDialog(
title: const Text('Discard changes?'),
actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Stay')),
TextButton(onPressed: () => Navigator.pop(dialogContext, true), child: const Text('Leave')),
],
));
return leave ?? false;
},
);
A veto can prevent completion of a requested pop, so pop returning true means a
pop was requested, not that an exit guard has already approved it. Keep guard
side effects idempotent: redirects may run again on refresh or restoration.
Dispose the router from its owner; it only cancels its own controller listener.
Imperative navigation
Nav stays available for ordinary Navigator apps, dialogs and typed page results:
final answer = await Nav.to<int>(context,
() => const Scaffold(body: Text('Choose an answer')));
if (!context.mounted) return;
// From that page: Nav.off<int>(context, 42);
Nav.pushUntil<void>(context, () => const Scaffold(body: Text('Dashboard')),
ModalRoute.withName('/home'));
Nav.offAll<void>(context, () => const Scaffold(body: Text('Login')));
offAll clears the whole stack; replace replaces the top. getArguments is
nullable outside a modal route; argumentsOf<T> casts the supplied arguments.
Named imperative routes still need Flutter's routes/onGenerateRoute configuration.
Use router APIs for URL-driven screens rather than mixing independently managed
stacks without an explicit navigation policy.
Migrating from 1.x
Version 2.0 intentionally changes the following contracts:
-
Legacy
ActiveMemory,Memory, HTTP request and WebSocket exports from 1.x are no longer included. Migrate persistent data and keys before upgrading; use dedicated storage/network packages. The gallery's QuickeyDB integration is an optional application recipe, not a drop-in replacement for the old storage API. -
field == otheruses wrapper identity. Use.equals(...)for value comparison. Hashes remain stable after updates, allowing wrappers as batch/map keys. -
Nav.pushUntiltakes aRoutePredicate, such asModalRoute.withName('/home'). The old widget predicate never correctly matched routes. -
Nav.offAllclears the entire stack. UseNav.replacefor the old top-only behavior. -
ActiveState.resetActivities()restores values. Dispose the controller explicitly from its owner; Flutter owns disposal of mounted State objects. -
ActiveModelnow extendsActiveType; register it with other fields. -
Injected Deamon controllers must be broadcast. Sticky replay is per subscriber with an exact generic cache key. The bus owns injected controllers.
-
Logger release output defaults to disabled; opt in explicitly when required.
-
Strict URL/date validation can reject previously accepted relative URLs and overflowing calendar dates. Custom rule callbacks may return null for success.
Additional changes from the first 2.0 development checkout:
- ActivityRouter now owns its Flutter delegate/parser instead of itself being a RouterDelegate
- Redirects settle asynchronously. Observe navigation/errorBuilder after the Router processes a location, rather than expecting go() to throw guard errors.
- push and replace return typed result futures. Constructors for ActivityRoute are not const because route trees are validated and copied.
- Stateful shell builders receive ActivityNavigationShell. Its goBranch retains and restores Activity's per-branch history. Use ActivityShellBranch for branches.
- ICU braces are syntax. Quote literal syntax characters in translation patterns; simple interpolation and missing-key fallbacks remain supported.
- intl is the runtime dependency; the former external routing dependency and its native exports have been removed. Replace raw GoRoute/ShellRoute types with ActivityRoute/ActivityShellRoute, and context.go/push with context.activityRouter.go/push.
The SDK language baseline remains Dart 3.7.2. Dart records, typed callbacks, immutable snapshots, and explicit resource ownership address the observed issues without forcing consumers onto newer language syntax. See the official Dart language evolution and Flutter Router API for version and navigation contracts.
Lifecycle and troubleshooting
| Symptom | Check |
|---|---|
| Field update throws an assignment exception | Return that exact field instance from activities. |
| View does not rebuild | Update through the wrapper; check registration and notification flags. |
| Scoped consumer stays stale | Use the matching generic controller type; omit the optional revision callback. |
| Two views show different data | Pass the same controller instance; do not create controllers inside build. |
| Reset does not restore nested data | Use immutable values; originals are references or shallow collection copies. |
| Controller throws after cleanup | resetActivities() closes it permanently; create a new controller. |
Have one owner dispose a controller. Views and scopes subscribe synchronously,
cancel their own subscriptions, and never dispose a shared controller.
ActiveState.resetActivities() now restores values and leaves the mounted state
usable. ActiveController.resetActivities() is the legacy terminal cleanup alias.
dispose() is idempotent and marks the controller disposed before awaiting any
cleanup. Mutating attached values after disposal throws before mutation.
The example compiles for web; regression tests run on the Flutter host VM and Chrome. See Observations for exact validation results; desktop/mobile device coverage is separate from compilation and unit tests.
Example, testing, and contribution
Run from the package directory:
flutter pub get
dart format lib test
flutter analyze
flutter test
flutter test --platform chrome test/events test/helpers test/navigation test/localization test/state/regressions_test.dart
See Observations.md for the review baseline, test results,
priorities, and Dart modernization plan. New Dart syntax must respect the minimum
SDK declared in pubspec.yaml; upgrading your installed SDK alone does not raise
the package language version.
Report bugs or propose features in the Activity issue tracker. Contributions should include relevant regression tests and updated API docs. See the Code of Conduct and Apache 2.0 license.
Support, gifts and community
Help fund and sustain open-source work. GitHub community · Report an issue · Blog & Articles · Activity on pub.dev.
Libraries
- activity
- Reactive state, controllers, routing and localization for Flutter.
- core/active
- core/events/deamon
- core/helpers/env
- core/helpers/env_platform_io
- core/helpers/env_platform_stub
- core/helpers/logger
- core/helpers/schema_validator
- core/helpers/strings
- core/helpers/uuid
- core/localization/icu_message
- core/localization/localizations
- core/navigation/activity_router
- core/src/builder
- core/src/controller
- core/src/errors
- core/src/exceptions
- core/src/helper
- core/src/state
- core/src/view
- core/types/active_models
- core/types/active_type
- core/types/type_helpers
- main
- widgets/carousel/src/carousel_controller
- widgets/carousel/src/carousel_slider