flutter_stasis 1.0.0
flutter_stasis: ^1.0.0 copied to clipboard
State management for Flutter built around explicit lifecycle, standardised async execution, and ephemeral UI events.
flutter_stasis #
Flutter layer of the Stasis ecosystem. Provides StasisViewModel, reactive widgets, and ephemeral UI events.
For the pure-Dart contracts (
StateObject,Command,CommandPolicy) seeflutter_stasis_core. For dartzEitherintegration seeflutter_stasis_dartz.
Installation #
dependencies:
flutter_stasis: ^1.0.0
StasisViewModel #
Base class for all ViewModels. Requires an initial StateObject and exposes helpers to transition lifecycle and execute async commands.
class ProjectsViewModel
extends StasisViewModel<AppFailure, List<Project>, ProjectsState> {
ProjectsViewModel(this._getProjects)
: super(ProjectsState.initial());
final GetProjectsUseCase _getProjects;
Future<void> load() => execute(
command: _getProjects,
onLoading: setLoading,
onError: setError,
onSuccess: setSuccess,
);
void setFilter(ProjectFilter f) =>
update((s) => s.copyWith(filter: f));
}
State helpers #
setLoading() // → LoadingState
setSuccess(data) // → SuccessState(data)
setError(failure) // → ErrorState(failure)
setInitialState() // → InitialState
update((s) => s.copyWith(...)) // update fields, keep lifecycle
Combine lifecycle transition + field update in one call with withUpdate:
setSuccess(
audioFile,
withUpdate: (s) => s.copyWith(waveform: audioFile.waveform),
);
setErroris the automatic fallback forexecutewhenonErroris not provided.
execute #
Single entry point for all async actions.
onSuccess is required — you must decide what to do with the result.
onError is optional — when omitted, setError is called automatically.
onLoading is optional — pass setLoading when you want a loading indicator.
// Full form
Future<void> load() => execute(
command: _getProjects,
onSuccess: setSuccess,
onError: setError, // optional, this is the default behaviour
onLoading: setLoading,
);
// Minimal — onError falls back to setError automatically
Future<void> load() => execute(
command: _getProjects,
onSuccess: setSuccess,
);
// Custom error handling — override the default
Future<void> load() => execute(
command: _getProjects,
onSuccess: setSuccess,
onError: (f) => emit(ShowSnackBarEvent(f.message)),
);
// With concurrency policy
Future<void> search(String query) => execute(
command: TaskCommand(() => _searchUseCase(query)),
onSuccess: setSuccess,
onLoading: setLoading,
policy: CommandPolicy.restartable,
);
See flutter_stasis_core for Command, CommandPolicy and result composition.
Lifecycle #
class _ScreenState extends State<MyScreen> {
late final vm = MyViewModel(getIt());
@override
void dispose() {
vm.dispose(); // clears state, events, and concurrency scope
super.dispose();
}
}
StasisBuilder #
Rebuilds whenever the StateObject changes:
StasisBuilder(
listenable: vm.stateListenable,
builder: (context, state, _) {
if (state.isLoading) return const Loader();
if (state.isError) return ErrorBanner(state.errorMessage!);
return ProjectList(state.projects ?? []);
},
);
StasisSelector #
Rebuilds only when the selected value changes. Use this inside StasisBuilder to avoid rebuilding heavy subtrees:
// Rebuilds only when isDragging changes
StasisSelector<AudioState, bool>(
listenable: vm.stateListenable,
selector: (state) => state.isDragging,
builder: (context, isDragging, _) => DropZone(active: isDragging),
);
Custom equality for complex types:
StasisSelector<AudioState, List<double>>(
listenable: vm.stateListenable,
selector: (state) => state.waveform,
equals: (a, b) => a.length == b.length,
builder: (context, waveform, _) => WaveformWidget(points: waveform),
);
StasisEventListener #
One-shot events that should never be stored in state — navigation, snackbars, dialogs.
The ViewModel dispatches; the View reacts:
// Define your events
final class ShowSnackBarEvent extends UiEvent {
const ShowSnackBarEvent(this.message);
final String message;
}
final class PopEvent extends UiEvent {
const PopEvent();
}
// ViewModel dispatches
void onSaved() {
emit(const ShowSnackBarEvent('Saved successfully'));
emit(const PopEvent());
}
// View reacts
StasisEventListener(
stream: vm.events,
onEvent: (context, event) async {
switch (event) {
case ShowSnackBarEvent(:final message):
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
case PopEvent():
Navigator.of(context).pop();
}
},
child: ...,
);
Full example #
// state
class CounterState extends StateObject<String, int, CounterState> {
const CounterState({required super.state});
int get count => dataOrNull ?? 0;
@override
CounterState withState(ViewModelState<String, int> state) =>
CounterState(state: state);
@override
List<Object?> get props => [state];
}
// view model
class CounterViewModel extends StasisViewModel<String, int, CounterState> {
CounterViewModel()
: super(const CounterState(state: SuccessState(0)));
Future<void> increment() => execute(
command: TaskCommand(() async {
await Future.delayed(const Duration(milliseconds: 200));
return CommandSuccess((state.count) + 1);
}),
onSuccess: setSuccess,
onLoading: setLoading,
policy: CommandPolicy.droppable,
);
}
// screen
class CounterScreen extends StatefulWidget {
const CounterScreen({super.key});
@override
State<CounterScreen> createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
final vm = CounterViewModel();
@override
void dispose() {
vm.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: StasisBuilder(
listenable: vm.stateListenable,
builder: (_, state, __) => state.isLoading
? const CircularProgressIndicator()
: Text('${state.count}', style: const TextStyle(fontSize: 48)),
),
),
floatingActionButton: FloatingActionButton(
onPressed: vm.increment,
child: const Icon(Icons.add),
),
);
}
Usage patterns #
Stasis works regardless of how your use cases return results. Pick the pattern that fits your project.
Pattern 1 — Use case with dartz + adapter installed #
The most ergonomic path if you already use dartz. Import flutter_stasis_dartz and call executeEither directly with your use case.
// use case (your domain layer — untouched)
class GetProjectsUseCase {
GetProjectsUseCase(this._repository);
final ProjectRepository _repository;
Future<Either<AppFailure, List<Project>>> call() =>
_repository.getAll();
}
// view model
import 'package:flutter_stasis_dartz/flutter_stasis_dartz.dart';
class ProjectsViewModel
extends StasisViewModel<AppFailure, List<Project>, ProjectsState> {
ProjectsViewModel(this._getProjects) : super(ProjectsState.initial());
final GetProjectsUseCase _getProjects;
Future<void> load() => executeEither(
command: _getProjects.call,
onSuccess: setSuccess, // onError omitted → setError called automatically
onLoading: setLoading,
);
}
Pattern 2 — Use case with dartz, no adapter #
If you use dartz but don't want to add flutter_stasis_dartz, convert the Either manually inside a TaskCommand. One extra line, no extra dependency.
// use case (same as above)
class GetProjectsUseCase {
Future<Either<AppFailure, List<Project>>> call() =>
_repository.getAll();
}
// view model — no adapter import needed
class ProjectsViewModel
extends StasisViewModel<AppFailure, List<Project>, ProjectsState> {
ProjectsViewModel(this._getProjects) : super(ProjectsState.initial());
final GetProjectsUseCase _getProjects;
Future<void> load() => execute(
command: TaskCommand(() async {
final result = await _getProjects();
return result.fold(
(failure) => CommandFailure(failure),
(data) => CommandSuccess(data),
);
}),
onSuccess: setSuccess, // onError omitted → setError called automatically
onLoading: setLoading,
);
}
Pattern 3 — No dartz, no adapter #
Use cases return plain Future and throw on failure, or you catch errors yourself. Wrap everything in a TaskCommand and return CommandSuccess / CommandFailure directly.
// use case — plain Future, throws on error
class GetProjectsUseCase {
Future<List<Project>> call() => _repository.getAll();
}
// view model
class ProjectsViewModel
extends StasisViewModel<AppFailure, List<Project>, ProjectsState> {
ProjectsViewModel(this._getProjects) : super(ProjectsState.initial());
final GetProjectsUseCase _getProjects;
Future<void> load() => execute(
command: TaskCommand(() async {
try {
final data = await _getProjects();
return CommandSuccess(data);
} on NetworkException catch (e) {
return CommandFailure(AppFailure(e.message));
} catch (e) {
return CommandFailure(AppFailure('Unexpected error'));
}
}),
onSuccess: setSuccess, // onError omitted → setError called automatically
onLoading: setLoading,
);
}
Alternatively, if you want the use case to already speak CommandResult, implement Command<F, R> directly — then pass it straight to execute with no wrapper:
// use case implements Command directly
class GetProjectsUseCase implements Command<AppFailure, List<Project>> {
@override
Future<CommandResult<AppFailure, List<Project>>> call() async {
try {
final data = await _repository.getAll();
return CommandSuccess(data);
} on NetworkException catch (e) {
return CommandFailure(AppFailure(e.message));
}
}
}
// view model — cleanest form, no wrapper, no onError needed
Future<void> load() => execute(
command: _getProjects, // use case IS the command
onSuccess: setSuccess,
);
License #
MIT