flutter_operations 4.0.0+2
flutter_operations: ^4.0.0+2 copied to clipboard
Type-safe async operation state management for Flutter using sealed classes and exhaustive pattern matching.
4.0.0 #
Breaking changes #
- Renamed the widget Future mixin to
AsyncOperationStateMixin<T, Widget>. The nameAsyncOperationMixin<T>now denotes a host-neutral mixin; existing widgets should rename their mixin, not simply remove a type argument. - Widget Future
globalRefreshnow schedules whole-widget rebuilding before lifecycle callbacks. Notifier publication already preceded callbacks in 3.x and retains that ordering. - Raised the Dart SDK minimum to 3.12. Use a Flutter SDK bundling Dart 3.12 or newer.
- Renamed the widget stream mixin to
StreamOperationStateMixin<T, Widget>and added host-neutralStreamOperationMixin<T>with explicit publication and awaitable disposal. - Stream widget
listennow returnsFuture<void>and waits for previous cleanup; added awaitablecancel. Async cleanup failures surface to callers (or the widget disposal zone).
Execution and integration #
-
Extracted
Operation<T>as the shared state-publication base for async and stream engines. Reactive hooks, cached transitions, error formatting, and message attachment share one implementation; execution and cleanup remain separate. -
Added
StreamOperation<T>for standalone subscriptions, serialized cancellation, latest-restart generation guards, scoped emission messages, reactive hooks, and awaitable cleanup. The widget stream mixin now delegates to it. -
Added
AsyncOperation<T>for host-owned Future or synchronous work, cached transitions, latest/first concurrency policies, cancellation of result publication, disposal guards, and run-scoped messages. -
Added constructor callbacks and overridable
onRead/onChanged(previous, next)hooks for reactive adapters. Change notification occurs once per unequal transition, before lifecycle callbacks. -
Added host-neutral
AsyncOperationMixin<T>withfetch,load/reload, state setters,cancel, explicit disposal, and read/change hooks. -
Added an overridable
operationControllerfor specialized or injected controllers. The default controller is lazily created; supplied controllers own their callbacks and lifecycle configuration. -
Scoped async message attachment to the receiving operation, preventing cross-operation message attachment.
-
Retained direct
OperationState<T>usage and widget stream lifecycle ownership under the renamed State adapter.
Examples and guidance #
- Added standalone, widget, Cubit, Riverpod, Provider, Signals, and MobX integrations for Future and Stream execution, including direct-state, composition, and host-mixin comparisons.
- Organized runnable examples under
example/lib/async/andexample/lib/stream/; added a local documentation site underdocs/. - Added MobX inheritance with
Store, Atom bridges, computed projections, reusable reactive mixins, getter overrides, and controller injection examples. - Reworked the bundled agent skill for 4.0, including framework recipes, ownership boundaries, reactive limitations, null-pattern guidance, and a 3.x migration guide.
- Added execution and integration regression coverage for cancellation, disposal, reactive publication, callback order, and injected ownership.
3.0.0 #
This release expands OperationState<T> into a complete standalone state model while keeping both widget mixins available for lifecycle-owned Future and Stream work.
Breaking Changes #
- Renamed
ErrorOperation.exceptiontoerror. The constructor argument is nowerror:. This reflects that Dart can throw anyObject, not onlyExceptionimplementations. - Renamed error callback parameters from
exceptiontoerror. Overrides oferrorMessageandonErrorshould use the new name. Their method signatures and behavior are otherwise unchanged. - Raised the minimum Flutter version from 3.16.0 to 3.32.0. This aligns the declared Flutter requirement with the package's Dart 3.8 minimum.
// Before
ErrorOperation(message: 'Failed', exception: error, stackTrace: stackTrace);
// After
ErrorOperation(message: 'Failed', error: error, stackTrace: stackTrace);
New Features #
- Added
copyWithto every operation state. Omitted fields retain their values. Explicitnullclears nullable fields according to each state's documented type constraints. - Added typed
transitionTohelpers. Transition between idle, loading, success, and error states without manually copying cached data. Loading, idle, and error transitions preserve current data whendatais omitted;data: nullclears it. - Restricted concrete transition helpers to other variants. For example, a promoted
SuccessOperation<T>can transition to loading, idle, or error. UsecopyWithwhen the variant should remain successful. A baseOperationState<T>reference exposes every destination because its runtime variant is not statically known.
state = state.transitionTo.loading();
state = state.transitionTo.success(data: result, message: 'Loaded');
state = state.transitionTo.error(
message: 'Refresh failed',
error: error,
stackTrace: stackTrace,
);
Documentation #
- Expanded the README beyond widget-local and BLoC examples. It now covers mixin-owned Future and Stream lifecycles, Cubit/BLoC, ChangeNotifier and plain controllers, Riverpod/Provider-style state holders, cached refreshes, command states, explicit transitions, and rendering patterns.
- Clarified
loadOnInit,listenOnInit, idle matching, and theasync*scope required for streamattachMessagecalls. - Updated public API and bundled skill documentation for the
errorterminology and new state helpers.
2.0.2 #
Bug Fixes #
- Fixed a latent zone-key collision in
attachMessage. The internal zone key was declaredconst Object(). Equivalent const expressions can be canonicalized to the same instance, allowing an unrelated zone value that used the same key expression to collide with it. It is now a uniquefinal Object(). No public API change.
2.0.1 #
Improvements #
- Hardened
attachMessageinternals with if-case pattern matching. The zone-cell lookup now usesif (cell case MessageCell cell?)instead of a manual null/type check, in bothAsyncOperationMixinandStreamOperationMixin. No behavior change.
Packaging #
- Added
.pubignoreto exclude build artifacts, IDE metadata, and cache directories from the published package.
2.0.0 #
A focused redesign on two fronts: the success state's type-honesty (no more SuccessOperation.empty() runtime trap), and the override surface (single fetch() / stream() plus an optional attachMessage(String) channel). The dual-method pattern is gone; the bool empty flag is gone; the StateError-throwing data getter is gone.
BREAKING CHANGES #
Override surface
- Removed
fetchWithMessage()andstreamWithMessage()overrides. Replaced by a single required override per mixin (fetch()/stream()) plus an optionalattachMessage(String)channel. fetch()andstream()are now abstract. Missing overrides surface as compile-time errors instead of runtimeStateErrors.- Removed
(T, String?)record return shape from override signatures. Optional success messages flow throughattachMessage(String)instead.
Success state
- Removed
SuccessOperation.empty()constructor andbool emptyfield. The dedicated "empty success" state added surface area to model what is already expressible via the type parameter (<void>for fire-and-forget,<T?>for legitimately optional payloads). SuccessOperation.datano longer throwsStateError. The previous "empty" runtime trap is gone.datareturns exactlyT: non-null whenTis non-nullable, nullable whenTis nullable.
New Features #
attachMessage(String)on bothAsyncOperationMixinandStreamOperationMixin. Call from insidefetch()(async) or before eachyield(streamasync*) to attach an optional message to the resultingSuccessOperation. Internally backed by a per-callZonecell, so concurrent fetches and re-listens are race-safe by construction.
Bug Fixes #
- Stream mixin
mountedguard on data callbacks. BothonDatapaths inStreamOperationMixinnow checkmountedbefore callingsetData. Previously, late stream emissions could write to a disposedValueNotifierafter the widget had unmounted. LoadingOperation.hashCodenow includesruntimeType. Previously,IdleOperation<T>(data: x)andLoadingOperation<T>(data: x)shared ahashCodewhile being unequal under==, causing poor distribution in hash-based collections. The fix usesObject.hash(runtimeType, data).
Why these changes #
The success state. SuccessOperation in 1.x carried a bool empty flag and a StateError-throwing data getter to support SuccessOperation.empty(). This forced the type system to lie: SuccessOperation<User>.data claimed non-null User while runtime could throw. The fix is to let T speak for itself: if the operation may have no value, the consumer says so via <User?> or <void>; otherwise data is guaranteed non-null with no runtime trap.
The override surface. The dual fetch() / fetchWithMessage() API required runtime validation ("exactly one must be overridden") and forced callers who wanted a message to wrap their result in a (T, String?) record. The new shape uses Dart's Zone to thread an optional message channel through fetch() without touching its return type. Calls to attachMessage from inside fetch (or before each yield inside stream) write to a per-call cell that the mixin reads when materializing the SuccessOperation. Concurrent fetches each get their own cell, so the race protection is structural.
How attachMessage works #
The mixins wrap each load() / listen() call in a runZoned block holding a per-call MessageCell. attachMessage reads Zone.current to find the cell. After fetch resolves (one-shot) or each emission arrives (stream), the mixin reads the cell synchronously and pairs the message with the value. The cell read happens before any await in the listener body, so async generator back-pressure pairs each yield with its own message.
class _UserState extends State<UserWidget>
with AsyncOperationMixin<User, UserWidget> {
@override
Future<User> fetch() async {
final response = await api.getUser();
if (response.serverMessage != null) attachMessage(response.serverMessage!);
return response.data;
}
}
Migration from 1.5.x #
SuccessOperation.empty() is gone: pick the right type parameter
If the operation never produces a value (delete, logout, fire-and-forget), parameterize with void:
// Before (1.5.x):
class DeleteCubit extends Cubit<OperationState<DeleteResult>> {
void run() {
// ... do the delete ...
emit(const SuccessOperation.empty());
}
}
// After (2.0.0): the cubit's T was a lie; the operation is fire-and-forget.
class DeleteCubit extends Cubit<OperationState<void>> {
void run() {
// ... do the delete ...
emit(const SuccessOperation(data: null));
}
}
If the operation may legitimately produce no value (current user when signed out, search result), parameterize with T?:
// Before (1.5.x): "logged-out" expressed as an empty success of <User>
class CurrentUserCubit extends Cubit<OperationState<User>> {
void signOut() => emit(const SuccessOperation.empty());
}
// After (2.0.0): the cubit's T is honestly nullable.
class CurrentUserCubit extends Cubit<OperationState<User?>> {
void signOut() => emit(const SuccessOperation(data: null));
}
state.empty is gone: qualify with the success type
state.empty was on SuccessOperation and implied success. state.hasNoData is on the base OperationState and is also true for LoadingOperation() and ErrorOperation() without cached data, so a naive replacement changes branch semantics:
// Before (1.5.x):
if (state is SuccessOperation && state.empty) { ... }
// After (2.0.0): keep the SuccessOperation check explicit
if (state is SuccessOperation && state.hasNoData) { ... }
// or use a pattern:
if (state case SuccessOperation(data: null)) { ... }
Pattern matching equivalents
switch (state) {
// Before:
SuccessOperation(empty: true) => const Text('Done'),
SuccessOperation(:var data) => DataView(data),
// After (for OperationState<User?>):
SuccessOperation(data: null) => const Text('Done'),
SuccessOperation(:var data?) => DataView(data),
// After (for OperationState<void>):
SuccessOperation() => const Text('Done'),
}
fetchWithMessage() and streamWithMessage() are gone: use attachMessage
// Before (1.5.x):
@override
Future<(User, String?)> fetchWithMessage() async {
final response = await api.getUser();
return (response.data, response.message);
}
// After (2.0.0):
@override
Future<User> fetch() async {
final response = await api.getUser();
if (response.message != null) attachMessage(response.message!);
return response.data;
}
Same shape for streams: drop the streamWithMessage() override and call attachMessage(...) before each yield inside stream() (which can be plain Stream<T> or async*).
1.5.0 #
New Features #
- Promoted
dataOrNullgetter toOperationStatebase class — Previously only available onSuccessOperation,dataOrNullis now accessible on all state types (LoadingOperation,IdleOperation,ErrorOperation,SuccessOperation). This allows safe nullable data access without pattern-matching first. ForSuccessOperation.empty()states, it returnsnullinstead of throwing like thedatagetter does.
Improvements #
- Replaced
print()withdeveloper.log()in defaultonErrorhandlers — BothAsyncOperationMixinandStreamOperationMixinnow usedart:developer'slog()for default error logging. This integrates with Flutter DevTools, provides structured metadata (error object, stack trace, category name), and is automatically filtered out in release builds. Zero new dependencies. - Fixed
analysis_options.yaml— Now correctly usespackage:flutter_lints/flutter.yamlto match theflutter_lintsdev dependency, enabling Flutter-specific lint rules. - Improved dual-override validation comments — Added clarifying comments explaining why the
fetch()/stream()validation call is side-effect-free in the happy path. - Added doc comment for nullable
Tedge case onSuccessOperation— Documents the behavior whenTitself is nullable (e.g.,SuccessOperation<String?>(data: null)).
Bug Fixes #
- Fixed
_NotImplementedException.toStringinStreamOperationMixin— Was incorrectly displayingAsyncOperationMixinExceptioninstead ofStreamOperationMixinException. - Made
idleparameter functional inStreamOperationMixin.setLoading— The parameter was previously accepted but never used. NowsetLoading(idle: true)correctly produces anIdleOperationand invokes theonIdlecallback. - Fixed
Product.examples()in example app —Random().nextInt(3)only selected from 3 of 9 categories. Now usesrandom.nextInt(categories.length)with a singleRandominstance. - Fixed timer leak in
AdvancedCustomHandlersExample— Addeddispose()override to cancel_retryTimerand_circuitBreakerTimer, preventing callbacks firing on unmounted widgets. - Fixed
BasicStreamExamplebuilder — Now uses thevalueparameter fromValueListenableBuilderinstead of readingoperationdirectly.
1.4.0 #
BREAKING CHANGES #
SuccessOperation.empty()no longer accepts adataparameter - The constructor now always creates a truly empty state. Previously, passingdatawould create a non-empty state withempty = false, which was confusing.
Bug Fixes #
- Fixed crash when comparing empty
SuccessOperationstates - The==operator andhashCodenow use the internal_datafield instead of calling the throwingdatagetter. This fixes issues with Bloc/Cubit state comparison when emittingSuccessOperation.empty(). - Fixed
hasData/hasNoDatagetters throwing on empty operations - These now safely check the internal field. - Fixed
toString()for empty operations - No longer throws when converting empty states to string.
New Features #
- Added
dataOrNullgetter toSuccessOperation- Provides safe nullable access to data without throwing. Use this when you're unsure if the operation is empty, or in contexts where you want to handle both cases uniformly.
Migration #
If you were using SuccessOperation.empty(data: someValue), this will no longer compile. This usage was semantically
incorrect - use SuccessOperation(data: someValue) instead for non-empty states.
// Before (incorrect usage that will no longer compile):
SuccessOperation.empty
(
data: myData) // ❌ Removed
// After (correct usage):
SuccessOperation(data: myData) // ✅ Use this for non-empty
SuccessOperation.
empty
(
) // ✅ Use this for truly empty
1.3.0 #
BREAKING CHANGES #
- Removed
OperationResult<T>class - Replaced with Dart records(T, String?)for less cpu and memory churn. fetchWithMessage()now returnsFutureOr<(T, String?)>instead ofFutureOr<OperationResult<T>>.streamWithMessage()now returnsStream<(T, String?)>instead ofStream<OperationResult<T>>.
Migration #
If you're using fetchWithMessage() or streamWithMessage(), update your code:
Before (1.2.0):
@override
Future<OperationResult<User>> fetchWithMessage() async {
final user = User.fromJson(json['data']);
final message = json['message'] as String?;
return OperationResult(user, message: message);
}
After (1.3.0):
@override
Future<(User, String?)> fetchWithMessage() async {
final user = User.fromJson(json['data']);
final message = json['message'] as String?;
return (user, message);
}
Before (1.2.0) - Streams:
@override
Stream<OperationResult<Message>> streamWithMessage() {
return messageStream.map((jsonMap) {
final data = Message.fromJson(jsonMap['data']);
final message = jsonMap['message'] as String?;
return OperationResult(data, message: message);
});
}
After (1.3.0) - Streams:
@override
Stream<(Message, String?)> streamWithMessage() {
return messageStream.map((jsonMap) {
final data = Message.fromJson(jsonMap['data']);
final message = jsonMap['message'] as String?;
return (data, message);
});
}
The behavior remains the same - the only change is the API surface. All other functionality, including message handling
in SuccessOperation, works exactly as before.
1.2.0 #
New #
- Added
OperationResult<T>class to hold data with optional success messages. - Added
fetchWithMessage()method toAsyncOperationMixinfor returning data with messages. - Added
streamWithMessage()method toStreamOperationMixinfor streams with messages. - Added optional
messagefield toSuccessOperation<T>for success-related information. - Updated
setSuccess()andsetData()methods to accept optionalmessageparameter.
Changed #
fetch()andfetchWithMessage()are now both optional - exactly one must be overridden.stream()andstreamWithMessage()are now both optional - exactly one must be overridden.- Smart method detection: tries
*WithMessage()first, falls back to standard method. - Throws an error messages when neither or both methods are overridden.
Usage #
// Simple case - no message
@override
Future<User> fetch() async => api.getUser();
// With message - use fetchWithMessage()
@override
Future<OperationResult<User>> fetchWithMessage() async {
// API returns a Map with 'data' and 'message' fields
final response = await http.get(Uri.parse('https://api.example.com/user'));
final json = jsonDecode(response.body);
// Decode the data
final user = User.fromJson(json['data']);
// Extract the message from server response
final message = json['message'] as String?;
return OperationResult(user, message: message);
}
Migration: #
- Existing code using
fetch()continues to work without changes. - To add success messages, override
fetchWithMessage()instead offetch(). - Access messages in pattern matching:
SuccessOperation(:var data, :var message?).
1.1.1 #
- Address format warnings.
1.1.0 #
BREAKING CHANGES: #
- Removed
idleparameter fromLoadingOperation - Added
IdleOperation<T>class extendingLoadingOperation<T> - Changed
LoadingOperationfromfinaltobaseclass - Added convenience getters:
hasNoData,isLoading,isIdle,isSuccess,isError, etc. - Added
SuccessOperation.empty()constructor andemptyproperty - Added
setIdle()method to both mixins - Removed
doesGlobalRefreshparameter from internal methods
Migration:
- Replace
LoadingOperation.idlechecks withoperation.isIdle - Handle
IdleOperationin pattern matching whenloadOnInit = false - Update equality checks due to
LoadingOperationstructure changes
1.0.1 #
- Update README.md
1.0.0 #
- Initial release.
