flutter_operations 4.0.0+2 copy "flutter_operations: ^4.0.0+2" to clipboard
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 name AsyncOperationMixin<T> now denotes a host-neutral mixin; existing widgets should rename their mixin, not simply remove a type argument.
  • Widget Future globalRefresh now 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-neutral StreamOperationMixin<T> with explicit publication and awaitable disposal.
  • Stream widget listen now returns Future<void> and waits for previous cleanup; added awaitable cancel. 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> with fetch, load/reload, state setters, cancel, explicit disposal, and read/change hooks.

  • Added an overridable operationController for 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/ and example/lib/stream/; added a local documentation site under docs/.
  • 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.exception to error. The constructor argument is now error:. This reflects that Dart can throw any Object, not only Exception implementations.
  • Renamed error callback parameters from exception to error. Overrides of errorMessage and onError should 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 copyWith to every operation state. Omitted fields retain their values. Explicit null clears nullable fields according to each state's documented type constraints.
  • Added typed transitionTo helpers. Transition between idle, loading, success, and error states without manually copying cached data. Loading, idle, and error transitions preserve current data when data is omitted; data: null clears it.
  • Restricted concrete transition helpers to other variants. For example, a promoted SuccessOperation<T> can transition to loading, idle, or error. Use copyWith when the variant should remain successful. A base OperationState<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 the async* scope required for stream attachMessage calls.
  • Updated public API and bundled skill documentation for the error terminology and new state helpers.

2.0.2 #

Bug Fixes #

  • Fixed a latent zone-key collision in attachMessage. The internal zone key was declared const 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 unique final Object(). No public API change.

2.0.1 #

Improvements #

  • Hardened attachMessage internals with if-case pattern matching. The zone-cell lookup now uses if (cell case MessageCell cell?) instead of a manual null/type check, in both AsyncOperationMixin and StreamOperationMixin. No behavior change.

Packaging #

  • Added .pubignore to 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() and streamWithMessage() overrides. Replaced by a single required override per mixin (fetch() / stream()) plus an optional attachMessage(String) channel.
  • fetch() and stream() are now abstract. Missing overrides surface as compile-time errors instead of runtime StateErrors.
  • Removed (T, String?) record return shape from override signatures. Optional success messages flow through attachMessage(String) instead.

Success state

  • Removed SuccessOperation.empty() constructor and bool empty field. 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.data no longer throws StateError. The previous "empty" runtime trap is gone. data returns exactly T: non-null when T is non-nullable, nullable when T is nullable.

New Features #

  • attachMessage(String) on both AsyncOperationMixin and StreamOperationMixin. Call from inside fetch() (async) or before each yield (stream async*) to attach an optional message to the resulting SuccessOperation. Internally backed by a per-call Zone cell, so concurrent fetches and re-listens are race-safe by construction.

Bug Fixes #

  • Stream mixin mounted guard on data callbacks. Both onData paths in StreamOperationMixin now check mounted before calling setData. Previously, late stream emissions could write to a disposed ValueNotifier after the widget had unmounted.
  • LoadingOperation.hashCode now includes runtimeType. Previously, IdleOperation<T>(data: x) and LoadingOperation<T>(data: x) shared a hashCode while being unequal under ==, causing poor distribution in hash-based collections. The fix uses Object.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 dataOrNull getter to OperationState base class — Previously only available on SuccessOperation, dataOrNull is now accessible on all state types (LoadingOperation, IdleOperation, ErrorOperation, SuccessOperation). This allows safe nullable data access without pattern-matching first. For SuccessOperation.empty() states, it returns null instead of throwing like the data getter does.

Improvements #

  • Replaced print() with developer.log() in default onError handlers — Both AsyncOperationMixin and StreamOperationMixin now use dart:developer's log() 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 uses package:flutter_lints/flutter.yaml to match the flutter_lints dev 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 T edge case on SuccessOperation — Documents the behavior when T itself is nullable (e.g., SuccessOperation<String?>(data: null)).

Bug Fixes #

  • Fixed _NotImplementedException.toString in StreamOperationMixin — Was incorrectly displaying AsyncOperationMixinException instead of StreamOperationMixinException.
  • Made idle parameter functional in StreamOperationMixin.setLoading — The parameter was previously accepted but never used. Now setLoading(idle: true) correctly produces an IdleOperation and invokes the onIdle callback.
  • Fixed Product.examples() in example app — Random().nextInt(3) only selected from 3 of 9 categories. Now uses random.nextInt(categories.length) with a single Random instance.
  • Fixed timer leak in AdvancedCustomHandlersExample — Added dispose() override to cancel _retryTimer and _circuitBreakerTimer, preventing callbacks firing on unmounted widgets.
  • Fixed BasicStreamExample builder — Now uses the value parameter from ValueListenableBuilder instead of reading operation directly.

1.4.0 #

BREAKING CHANGES #

  • SuccessOperation.empty() no longer accepts a data parameter - The constructor now always creates a truly empty state. Previously, passing data would create a non-empty state with empty = false, which was confusing.

Bug Fixes #

  • Fixed crash when comparing empty SuccessOperation states - The == operator and hashCode now use the internal _data field instead of calling the throwing data getter. This fixes issues with Bloc/Cubit state comparison when emitting SuccessOperation.empty().
  • Fixed hasData/hasNoData getters 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 dataOrNull getter to SuccessOperation - 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 returns FutureOr<(T, String?)> instead of FutureOr<OperationResult<T>>.
  • streamWithMessage() now returns Stream<(T, String?)> instead of Stream<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 to AsyncOperationMixin for returning data with messages.
  • Added streamWithMessage() method to StreamOperationMixin for streams with messages.
  • Added optional message field to SuccessOperation<T> for success-related information.
  • Updated setSuccess() and setData() methods to accept optional message parameter.

Changed #

  • fetch() and fetchWithMessage() are now both optional - exactly one must be overridden.
  • stream() and streamWithMessage() 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 of fetch().
  • Access messages in pattern matching: SuccessOperation(:var data, :var message?).

1.1.1 #

  • Address format warnings.

1.1.0 #

BREAKING CHANGES: #

  • Removed idle parameter from LoadingOperation
  • Added IdleOperation<T> class extending LoadingOperation<T>
  • Changed LoadingOperation from final to base class
  • Added convenience getters: hasNoData, isLoading, isIdle, isSuccess, isError, etc.
  • Added SuccessOperation.empty() constructor and empty property
  • Added setIdle() method to both mixins
  • Removed doesGlobalRefresh parameter from internal methods

Migration:

  • Replace LoadingOperation.idle checks with operation.isIdle
  • Handle IdleOperation in pattern matching when loadOnInit = false
  • Update equality checks due to LoadingOperation structure changes

1.0.1 #

  • Update README.md

1.0.0 #

  • Initial release.
4
likes
160
points
370
downloads
screenshot

Documentation

API reference

Publisher

verified publishersaad-ardati.dev

Weekly Downloads

Type-safe async operation state management for Flutter using sealed classes and exhaustive pattern matching.

Repository (GitHub)
View/report issues

Topics

#async #state-management #pattern-matching #mixin #operations

License

BSD-3-Clause (license)

Dependencies

flutter

More

Packages that depend on flutter_operations