flutter_operations 2.0.0
flutter_operations: ^2.0.0 copied to clipboard
Type-safe async operation state management for Flutter using sealed classes and exhaustive pattern matching.
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.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.
