diff --git a/crates/dust_driver/tests/driver_tests/pub_workspace.rs b/crates/dust_driver/tests/driver_tests/pub_workspace.rs index 26e5212d..938728d4 100644 --- a/crates/dust_driver/tests/driver_tests/pub_workspace.rs +++ b/crates/dust_driver/tests/driver_tests/pub_workspace.rs @@ -85,7 +85,7 @@ fn watch_rebuilds_member_package_when_shared_workspace_config_changes() { fail_fast: false, jobs: None, poll_interval_ms: 20, - max_cycles: Some(3), + max_cycles: Some(50), }); modifier.join().unwrap(); diff --git a/crates/dust_driver/tests/driver_tests/state_outputs.rs b/crates/dust_driver/tests/driver_tests/state_outputs.rs index d1003cf2..2f7ba228 100644 --- a/crates/dust_driver/tests/driver_tests/state_outputs.rs +++ b/crates/dust_driver/tests/driver_tests/state_outputs.rs @@ -1,4 +1,4 @@ -use std::fs; +use std::{fs, path::PathBuf}; use dust_driver::{BuildRequest, run_build}; @@ -23,9 +23,39 @@ fn build_writes_state_output_for_view_model_library() { assert!(output.exists()); assert!(source.contains("abstract class $TaskBoardViewModel")); assert!(source.contains("extends ViewModelBase")); - assert!(!source.contains("enum _TaskBoardViewModelAspect")); + assert!(!source.contains("_TaskBoardViewModelAspect")); assert!(source.contains("TaskBoardState get value")); - assert!(source.contains("R select(R Function(TaskBoardState state) selector)")); + assert!(!source.contains("select")); + assert!(!source.contains("int get count")); + assert!(!source.contains("String? get message")); +} + +#[test] +fn build_writes_async_state_output_for_view_model_library() { + let workspace = make_workspace(); + write_async_state_workspace(workspace.path()); + + let result = run_build(BuildRequest { + cwd: workspace.path().to_path_buf(), + fail_fast: true, + jobs: None, + db: Default::default(), + }); + let output = workspace.path().join("lib/profile_view_model.g.dart"); + let source = fs::read_to_string(&output).unwrap(); + + assert!(!result.has_errors(), "{:?}", result.diagnostics); + assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); + assert!(output.exists()); + assert!(source.contains("abstract class $ProfileViewModel")); + assert!(source.contains("extends AsyncViewModelBase")); + assert!(source.contains("AsyncState get value")); + assert!(source.contains("final R Function(AsyncState state) selector")); + assert!(source.contains("class ProfileViewModelBuilder extends StatelessWidget")); + assert!(source.contains("final Widget Function(BuildContext context, Profile data) data")); + assert!(!source.contains("const Profile()")); + assert!(!source.contains("initialState:")); + assert_state_snapshot("async_profile_view_model.dart.snapshot", &source); } fn write_state_workspace(root: &std::path::Path) { @@ -49,3 +79,36 @@ fn write_state_workspace(root: &std::path::Path) { }\n", ); } + +fn write_async_state_workspace(root: &std::path::Path) { + write_file( + &root.join("lib/profile_view_model.dart"), + "import 'package:dust_flutter/state.dart';\n\ + part 'profile_view_model.g.dart';\n\ + final class Profile { const Profile(this.name); final String name; }\n\ + final class ProfileRepository { const ProfileRepository(); }\n\ + final class ProfileArgs extends ViewModelArgs {\n\ + const ProfileArgs({required this.repository, super.observer});\n\ + final ProfileRepository repository;\n\ + }\n\ + @ViewModel(state: Profile, args: ProfileArgs, mode: ViewModelMode.async)\n\ + final class ProfileViewModel extends $ProfileViewModel {\n\ + ProfileViewModel(super.args);\n\ + @override\n\ + Future loadData() async => const Profile('Ada');\n\ + }\n", + ); +} + +fn assert_state_snapshot(name: &str, actual: &str) { + let path = state_snapshot_path(name); + let expected = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("missing state snapshot `{}`: {error}", path.display())); + assert_eq!(actual, expected, "state snapshot `{name}` changed"); +} + +fn state_snapshot_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/driver_tests/state_outputs/snapshots") + .join(name) +} diff --git a/crates/dust_driver/tests/driver_tests/state_outputs/snapshots/async_profile_view_model.dart.snapshot b/crates/dust_driver/tests/driver_tests/state_outputs/snapshots/async_profile_view_model.dart.snapshot new file mode 100644 index 00000000..e779ba59 --- /dev/null +++ b/crates/dust_driver/tests/driver_tests/state_outputs/snapshots/async_profile_view_model.dart.snapshot @@ -0,0 +1,446 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, unused_import, deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: use_function_type_syntax_for_parameters +// ignore_for_file: unnecessary_const, avoid_init_to_null +// ignore_for_file: invalid_override_different_default_values_named +// ignore_for_file: prefer_expression_function_bodies +// ignore_for_file: annotate_overrides, invalid_annotation_target +// ignore_for_file: unnecessary_question_mark +// Generated by dust. + +part of 'profile_view_model.dart'; + +/// Async base class generated for ProfileViewModel. +/// +/// Extend it from your ViewModel and implement `loadData`. +/// +/// ```dart +/// final class ProfileViewModel extends $ProfileViewModel { +/// ProfileViewModel(super.args); +/// +/// @override +/// Future loadData() { +/// return args.repository.load(); +/// } +/// } +/// ``` +abstract class $ProfileViewModel extends AsyncViewModelBase { + $ProfileViewModel(super.args); +} + +/// Reads ProfileViewModel state from `BuildContext`. +/// +/// Read `value` when the widget should rebuild for any state change. +/// +/// ```dart +/// final state = context.watchProfileViewModel().value; +/// ``` +class _$ProfileViewModelProxy { + _$ProfileViewModelProxy(this._context); + + final BuildContext _context; + + AsyncState get value { + return ProfileViewModelScope.of(_context).value; + } +} + +/// Rebuilds when a selected AsyncState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class ProfileViewModelSelector extends StatefulWidget { + const ProfileViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(AsyncState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _ProfileViewModelSelectorState(); +} + +class _ProfileViewModelSelectorState extends State> { + ProfileViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = ProfileViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget(ProfileViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } + + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); + } +} + +/// Builds the common loading, data, and error UI for ProfileViewModel. +class ProfileViewModelBuilder extends StatelessWidget { + const ProfileViewModelBuilder({ + super.key, + required this.loading, + required this.data, + required this.error, + }); + + final Widget Function(BuildContext context) loading; + final Widget Function(BuildContext context, Profile data) data; + final Widget Function( + BuildContext context, + Object error, + Profile? previousData, + ) error; + + @override + Widget build(BuildContext context) { + final state = context.watchProfileViewModel().value; + return switch (state) { + AsyncData(data: final loadedData) => + data(context, loadedData), + AsyncLoading( + hasPreviousData: true, + previousData: final previousData, + ) => data(context, previousData as Profile), + AsyncFailure( + error: final loadError, + previousData: final previousData, + ) => error(context, loadError, previousData), + _ => loading(context), + }; + } +} + +/// Creates and provides ProfileViewModel to descendants. +/// +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. +/// +/// ```dart +/// ProfileViewModelScope( +/// args: (context) => ProfileArgs(...), +/// create: (context, args) => ProfileViewModel(args), +/// child: const FeaturePage(), +/// ) +/// ``` +class ProfileViewModelScope extends StatefulWidget { + /// Creates and owns ProfileViewModel from typed args. + const ProfileViewModelScope({ + super.key, + required this.args, + required this.create, + required this.child, + this.identity, + }) : value = null; + + /// Provides an externally owned ProfileViewModel without disposing it. + const ProfileViewModelScope.value({ + super.key, + required ProfileViewModel this.value, + required this.child, + }) : args = null, + create = null, + identity = null; + + final ProfileArgs Function(BuildContext context)? args; + final ProfileViewModel Function(BuildContext context, ProfileArgs args)? create; + final Object? Function(BuildContext context)? identity; + final ProfileViewModel? value; + final Widget child; + + /// Reads ProfileViewModel without rebuilding when state changes. + static ProfileViewModel read(BuildContext context) { + final scope = context + .getElementForInheritedWidgetOfExactType<_ProfileViewModelInstance>() + ?.widget as _ProfileViewModelInstance?; + if (scope == null) throw StateError('No ProfileViewModelScope found in context.'); + return scope.viewModel; + } + + static ProfileViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ProfileViewModelInstance>(); + if (scope == null) throw StateError('No ProfileViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches ProfileViewModel and rebuilds when state changes. + static ProfileViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ProfileViewModelInherited>(); + if (scope == null) throw StateError('No ProfileViewModelScope found in context.'); + return scope.viewModel; + } + + @override + State createState() => _ProfileViewModelScopeState(); +} + +class _ProfileViewModelScopeState extends State { + ProfileViewModel? _viewModel; + Object? _identity; + bool _ownsViewModel = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); + } + } + + @override + void didUpdateWidget(ProfileViewModelScope oldWidget) { + super.didUpdateWidget(oldWidget); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false); + } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } + } + } + + ProfileViewModel _createOwnedViewModel() { + final argsFactory = widget.args; + final create = widget.create; + if (argsFactory == null || create == null) { + throw StateError('Owned ProfileViewModelScope requires args and create.'); + } + late final ProfileViewModel created; + try { + created = create(context, argsFactory(context)); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + StateError( + 'ProfileViewModelScope failed to create its view model. Check the generated ' + 'scope args/create dependency injection. Original error: $error', + ), + stackTrace, + ); + } + return created; + } + + void _replaceViewModel( + ProfileViewModel nextViewModel, { + required bool ownsViewModel, + bool notify = true, + }) { + final previous = _viewModel; + if (identical(previous, nextViewModel)) { + _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); + if (notify && mounted) setState(() {}); + return; + } + previous?.removeListener(_onViewModelStateChanged); + if (_ownsViewModel) previous?.dispose(); + _viewModel = nextViewModel; + _ownsViewModel = ownsViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _scheduleInit(nextViewModel); + if (notify && mounted) setState(() {}); + } + + void _scheduleInit(ProfileViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('ProfileViewModelScope init failed'), + ), + ); + } + }); + } + + void _onViewModelStateChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + final viewModel = _viewModel; + viewModel?.removeListener(_onViewModelStateChanged); + if (_ownsViewModel) viewModel?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final viewModel = _viewModel; + if (viewModel == null) { + throw StateError('ProfileViewModelScope built before its view model was initialized.'); + } + return _ProfileViewModelInstance( + viewModel: viewModel, + child: _ProfileViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), + ); + } +} + +class _ProfileViewModelInstance extends InheritedWidget { + const _ProfileViewModelInstance({required this.viewModel, required super.child}); + + final ProfileViewModel viewModel; + + @override + bool updateShouldNotify(_ProfileViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _ProfileViewModelInherited extends InheritedWidget { + const _ProfileViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final ProfileViewModel viewModel; + final AsyncState state; + + @override + bool updateShouldNotify(_ProfileViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; + } +} + +/// Handles one-shot effects from ProfileViewModel. +/// +/// Effects are delivered without changing state and do not rebuild `child`. +/// +/// ```dart +/// ProfileViewModelListener( +/// listener: onEffect, +/// child: const FeaturePage(), +/// ) +/// ``` +class ProfileViewModelListener extends StatefulWidget { + const ProfileViewModelListener({super.key, required this.listener, required this.child}); + + final void Function(BuildContext context, Object effect) listener; + final Widget child; + + @override + State createState() => _ProfileViewModelListenerState(); +} + +class _ProfileViewModelListenerState extends State { + StreamSubscription? _sub; + ProfileViewModel? _viewModel; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = ProfileViewModelScope._watchInstance(context); + if (_viewModel == nextViewModel) return; + _sub?.cancel(); + _viewModel = nextViewModel; + _sub = nextViewModel.effects.listen(_onEffect); + } + + void _onEffect(Object effect) { + if (mounted) widget.listener(context, effect); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +/// BuildContext helpers generated for ProfileViewModel. +/// +/// ```dart +/// final vm = context.readProfileViewModel(); +/// final state = context.watchProfileViewModel().value; +/// ``` +extension ProfileViewModelBuildContext on BuildContext { + _$ProfileViewModelProxy watchProfileViewModel() { + return _$ProfileViewModelProxy(this); + } + + ProfileViewModel readProfileViewModel() => ProfileViewModelScope.read(this); +} diff --git a/crates/dust_state_plugin/src/plugin/analysis.rs b/crates/dust_state_plugin/src/plugin/analysis.rs index 24470e45..ec965b99 100644 --- a/crates/dust_state_plugin/src/plugin/analysis.rs +++ b/crates/dust_state_plugin/src/plugin/analysis.rs @@ -28,6 +28,7 @@ pub(crate) fn collect_state_workspace_analysis( state_type: annotation.state_type, args_type: annotation.args_type, initial_source: annotation.initial_source, + mode: annotation.mode, generated_base_class: format!("${}", class.name), import_uri: import_uri(context), }; diff --git a/crates/dust_state_plugin/src/plugin/emit.rs b/crates/dust_state_plugin/src/plugin/emit.rs index 510d96a6..559c4927 100644 --- a/crates/dust_state_plugin/src/plugin/emit.rs +++ b/crates/dust_state_plugin/src/plugin/emit.rs @@ -5,22 +5,11 @@ use super::parse::{parse_view_model_config, view_model_config}; /// Template rendering for generated view model support classes. mod render; -/// State field discovery used by generated proxy selectors. -mod state_fields; -use self::{render::render_view_model_output, state_fields::class_fields}; - -/// Field metadata passed from state discovery to the view model renderer. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct StateFieldSpec { - /// Dart field name read from the state object. - pub(super) name: String, - /// Dart type source emitted for the generated selector getter. - pub(super) type_source: String, -} +use self::render::render_view_model_output; /// Emits generated support types for all view models in a resolved library. -pub(crate) fn emit_library_state(library: &DartFileIr, plan: &SymbolPlan) -> PluginContribution { +pub(crate) fn emit_library_state(library: &DartFileIr, _plan: &SymbolPlan) -> PluginContribution { let mut contribution = PluginContribution::default(); let view_models = library .classes @@ -35,20 +24,18 @@ pub(crate) fn emit_library_state(library: &DartFileIr, plan: &SymbolPlan) -> Plu return contribution; } - let state_facts = state_fields::state_facts(plan); for (class, annotation) in view_models { let args_type = annotation .args_type .clone() .unwrap_or_else(|| "ViewModelArgs".to_owned()); - let state_fields = class_fields(library, &state_facts, &annotation.state_type); contribution.support_types.push(render_view_model_output( class, &annotation.state_type, &args_type, annotation.initial_source.as_deref(), - &state_fields, + annotation.mode, )); } contribution diff --git a/crates/dust_state_plugin/src/plugin/emit/render.rs b/crates/dust_state_plugin/src/plugin/emit/render.rs index 837693be..925856db 100644 --- a/crates/dust_state_plugin/src/plugin/emit/render.rs +++ b/crates/dust_state_plugin/src/plugin/emit/render.rs @@ -2,52 +2,7 @@ use dust_dart_emit::render_template; use dust_ir::ClassIr; use serde::Serialize; -use super::StateFieldSpec; - -/// Root template context for a generated view model support block. -#[derive(Serialize)] -struct ViewModelOutputContext { - /// Rendered aspect class and selector factories. - aspect: String, - /// Rendered generated base class. - base: String, - /// Rendered proxy object exposed from build context helpers. - proxy: String, - /// Rendered scope widget. - scope: String, - /// Rendered inherited widget backing the scope. - inherited: String, - /// Rendered listener widget. - listener: String, - /// Rendered build context extension. - extension: String, -} - -/// Template context for the generated inherited-model aspect type. -#[derive(Serialize)] -struct AspectContext<'a> { - /// Generated Dart aspect class name. - aspect_class: &'a str, - /// Dart state type represented by the aspect. - state_type: &'a str, -} - -/// Template context for a generated field-specific aspect selector. -#[derive(Serialize)] -struct AspectSelectorContext<'a> { - /// Generated Dart aspect class name. - aspect_class: &'a str, - /// Dart state type represented by the selector. - state_type: &'a str, - /// Lower camel form of the view model class used in selector names. - vm_lower: String, - /// Pascal form of the field name used in selector names. - field_pascal: String, - /// Dart field name read from the state object. - field_name: &'a str, - /// Dart field type emitted for the selector result. - field_type: &'a str, -} +use crate::plugin::model::ViewModelMode; /// Template context for the generated abstract view model base class. #[derive(Serialize)] @@ -64,6 +19,19 @@ struct BaseContext<'a> { initial_state: &'a str, } +/// Template context for the generated async view model base class. +#[derive(Serialize)] +struct AsyncBaseContext<'a> { + /// Generated base class name that user view models extend. + generated_base: &'a str, + /// User-authored view model class name. + view_model_class: &'a str, + /// Dart data type loaded by the view model. + data_type: &'a str, + /// Dart args type passed to the generated base. + args_type: &'a str, +} + /// Template context for the generated build context proxy class. #[derive(Serialize)] struct ProxyContext<'a> { @@ -75,25 +43,36 @@ struct ProxyContext<'a> { view_model_class: &'a str, /// Dart state type managed by the view model. state_type: &'a str, - /// Generated aspect class name used by selectors. - aspect_class: &'a str, - /// Rendered field-specific proxy getters. - field_getters: String, } -/// Template context for one field-specific proxy getter. +/// Template context for the generated selector widget. #[derive(Serialize)] -struct ProxyFieldContext<'a> { - /// Generated scope class name used for selection. +struct SelectorContext<'a> { + /// Generated public selector widget class name. + selector_class: &'a str, + /// Generated private selector state class name. + selector_state_class: &'a str, + /// Generated scope class name used for lookups. scope_class: &'a str, - /// Lower camel form of the selector prefix. - vm_lower: String, - /// Pascal form of the field name used in getter names. - field_pascal: String, - /// Dart field name read from the state object. - field_name: &'a str, - /// Dart field type emitted for the getter result. - field_type: &'a str, + /// User-authored view model class name. + view_model_class: &'a str, + /// Dart state type managed by the view model. + state_type: &'a str, +} + +/// Template context for the generated async builder widget. +#[derive(Serialize)] +struct AsyncBuilderContext<'a> { + /// Generated public builder widget class name. + builder_class: &'a str, + /// User-authored view model class name. + view_model_class: &'a str, + /// Dart data type loaded by the view model. + data_type: &'a str, + /// Dart type used for optional previous data. + previous_data_type: &'a str, + /// Dart expression used to pass previous data to the data builder. + previous_data_argument: &'a str, } /// Template context for the generated view model scope widget. @@ -101,14 +80,23 @@ struct ProxyFieldContext<'a> { struct ScopeContext<'a> { /// Generated public scope class name. scope_class: &'a str, + /// Generated private instance inherited widget class name. + instance_class: &'a str, /// Generated private inherited widget class name. inherited_class: &'a str, /// User-authored view model class name. view_model_class: &'a str, /// Dart args type accepted by the scope. args_type: &'a str, - /// Generated aspect class name used for inherited model notifications. - aspect_class: &'a str, +} + +/// Template context for the generated identity-only inherited widget. +#[derive(Serialize)] +struct InstanceContext<'a> { + /// Generated inherited widget class name. + instance_class: &'a str, + /// User-authored view model class name. + view_model_class: &'a str, } /// Template context for the generated inherited widget. @@ -120,8 +108,6 @@ struct InheritedContext<'a> { view_model_class: &'a str, /// Dart state type exposed through the inherited widget. state_type: &'a str, - /// Generated aspect class name used for update filtering. - aspect_class: &'a str, } /// Template context for the generated listener widget. @@ -160,46 +146,70 @@ pub(super) fn render_view_model_output( state_type: &str, args_type: &str, initial_source: Option<&str>, - state_fields: &[StateFieldSpec], + mode: ViewModelMode, ) -> String { let generated_base = format!("${}", class.name); let proxy_class = format!("_${}Proxy", class.name); + let selector_class = format!("{}Selector", class.name); + let selector_state_class = format!("_{}SelectorState", class.name); + let builder_class = format!("{}Builder", class.name); let scope_class = format!("{}Scope", class.name); + let instance_class = format!("_{}Instance", class.name); let inherited_class = format!("_{}Inherited", class.name); let listener_class = format!("{}Listener", class.name); let listener_state_class = format!("_{}ListenerState", class.name); let extension_class = format!("{}BuildContext", class.name); - let aspect_class = format!("_{}Aspect", class.name); let watch_name = format!("watch{}", class.name); let read_name = format!("read{}", class.name); let initial_state = initial_source .map(str::to_owned) .unwrap_or_else(|| format!("const {state_type}()")); - let base = render_base( - &generated_base, - &class.name, - state_type, - args_type, - &initial_state, - ); - let aspect = render_aspect_class(&aspect_class, &class.name, state_type, state_fields); + let generated_state_type = match mode { + ViewModelMode::Sync => state_type.to_owned(), + ViewModelMode::Async => format!("AsyncState<{state_type}>"), + }; + let base = match mode { + ViewModelMode::Sync => render_base( + &generated_base, + &class.name, + state_type, + args_type, + &initial_state, + ), + ViewModelMode::Async => { + render_async_base(&generated_base, &class.name, state_type, args_type) + } + }; let proxy = render_proxy( &proxy_class, &scope_class, &class.name, - state_type, - &aspect_class, + &generated_state_type, + ); + let selector = render_selector( + &selector_class, + &selector_state_class, + &scope_class, &class.name, - state_fields, + &generated_state_type, ); + let async_builder = match mode { + ViewModelMode::Sync => None, + ViewModelMode::Async => Some(render_async_builder( + &builder_class, + &class.name, + state_type, + )), + }; let scope = render_scope( &scope_class, + &instance_class, &inherited_class, &class.name, args_type, - &aspect_class, ); - let inherited = render_inherited(&inherited_class, &class.name, state_type, &aspect_class); + let instance = render_instance(&instance_class, &class.name); + let inherited = render_inherited(&inherited_class, &class.name, &generated_state_type); let listener = render_listener( &listener_class, &listener_state_class, @@ -219,61 +229,16 @@ pub(super) fn render_view_model_output( }, ); - render_template( - "view_model_output", - include_str!("templates/view_model_output.jinja"), - ViewModelOutputContext { - aspect, - base, - proxy, - scope, - inherited, - listener, - extension, - }, - ) -} - -/// Renders the aspect class and any field-specific selector factories. -fn render_aspect_class( - aspect_class: &str, - view_model_class: &str, - state_type: &str, - state_fields: &[StateFieldSpec], -) -> String { - let field_selectors = state_fields - .iter() - .map(|field| { - render_template( - "aspect_selector", - include_str!("templates/aspect_selector.jinja"), - AspectSelectorContext { - aspect_class, - state_type, - vm_lower: lower_camel(view_model_class), - field_pascal: pascal_case(&field.name), - field_name: &field.name, - field_type: &field.type_source, - }, - ) - }) + let mut sections = vec![base, proxy, selector]; + if let Some(async_builder) = async_builder { + sections.push(async_builder); + } + sections.extend([scope, instance, inherited, listener, extension]); + sections + .into_iter() + .map(|section| section.trim().to_owned()) .collect::>() - .join("\n\n"); - [ - render_template( - "aspect_class", - include_str!("templates/aspect_class.jinja"), - AspectContext { - aspect_class, - state_type, - }, - ), - field_selectors, - ] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n\n") + .join("\n\n") } /// Renders the abstract generated base class for a view model. @@ -297,32 +262,32 @@ fn render_base( ) } -/// Renders the build context proxy and its field selectors. +/// Renders the async generated base class for a view model. +fn render_async_base( + generated_base: &str, + view_model_class: &str, + data_type: &str, + args_type: &str, +) -> String { + render_template( + "base_async_class", + include_str!("templates/base_async_class.jinja"), + AsyncBaseContext { + generated_base, + view_model_class, + data_type, + args_type, + }, + ) +} + +/// Renders the build context proxy. fn render_proxy( proxy_class: &str, scope_class: &str, view_model_class: &str, state_type: &str, - aspect_class: &str, - selector_prefix: &str, - state_fields: &[StateFieldSpec], ) -> String { - let field_getters = state_fields - .iter() - .map(|field| { - render_template( - "proxy_field_getter", - include_str!("templates/proxy_field_getter.jinja"), - ProxyFieldContext { - scope_class, - vm_lower: lower_camel(selector_prefix), - field_pascal: pascal_case(&field.name), - field_name: &field.name, - field_type: &field.type_source, - }, - ) - }) - .collect::(); render_template( "proxy_class", include_str!("templates/proxy_class.jinja"), @@ -331,40 +296,101 @@ fn render_proxy( scope_class, view_model_class, state_type, - aspect_class, - field_getters, }, ) } +/// Renders the selector widget. +fn render_selector( + selector_class: &str, + selector_state_class: &str, + scope_class: &str, + view_model_class: &str, + state_type: &str, +) -> String { + render_template( + "selector_class", + include_str!("templates/selector_class.jinja"), + SelectorContext { + selector_class, + selector_state_class, + scope_class, + view_model_class, + state_type, + }, + ) +} + +/// Renders the async builder widget. +fn render_async_builder(builder_class: &str, view_model_class: &str, data_type: &str) -> String { + let previous_data_type = previous_data_type(data_type); + let previous_data_argument = previous_data_argument(data_type); + render_template( + "async_builder_class", + include_str!("templates/async_builder_class.jinja"), + AsyncBuilderContext { + builder_class, + view_model_class, + data_type, + previous_data_type: &previous_data_type, + previous_data_argument: &previous_data_argument, + }, + ) +} + +/// Returns the nullable type used by async error callbacks. +fn previous_data_type(data_type: &str) -> String { + if data_type.trim_end().ends_with('?') { + data_type.to_owned() + } else { + format!("{data_type}?") + } +} + +/// Returns the previous-data expression passed to async data builders. +fn previous_data_argument(data_type: &str) -> String { + if data_type.trim_end().ends_with('?') { + "previousData".to_owned() + } else { + format!("previousData as {data_type}") + } +} + /// Renders the scope widget that owns a view model instance. fn render_scope( scope_class: &str, + instance_class: &str, inherited_class: &str, view_model_class: &str, args_type: &str, - aspect_class: &str, ) -> String { render_template( "scope_class", include_str!("templates/scope_class.jinja"), ScopeContext { scope_class, + instance_class, inherited_class, view_model_class, args_type, - aspect_class, }, ) } -/// Renders the inherited widget used for aspect-scoped rebuilds. -fn render_inherited( - inherited_class: &str, - view_model_class: &str, - state_type: &str, - aspect_class: &str, -) -> String { +/// Renders the identity-only inherited widget. +fn render_instance(instance_class: &str, view_model_class: &str) -> String { + render_template( + "instance_class", + include_str!("templates/instance_class.jinja"), + InstanceContext { + instance_class, + view_model_class, + }, + ) +} + +/// Renders the inherited widget used for full-state rebuilds. +fn render_inherited(inherited_class: &str, view_model_class: &str, state_type: &str) -> String { render_template( "inherited_class", include_str!("templates/inherited_class.jinja"), @@ -372,7 +398,6 @@ fn render_inherited( inherited_class, view_model_class, state_type, - aspect_class, }, ) } @@ -396,26 +421,18 @@ fn render_listener( ) } -/// Converts a PascalCase identifier to lowerCamelCase. -fn lower_camel(value: &str) -> String { - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - first.to_ascii_lowercase().to_string() + chars.as_str() -} +#[cfg(test)] +mod tests { + use super::render_async_builder; + + #[test] + fn async_builder_uses_nullable_previous_data_type_once() { + let source = + render_async_builder("ProfileViewModelBuilder", "ProfileViewModel", "Profile?"); -/// Converts a snake_case identifier to PascalCase. -fn pascal_case(value: &str) -> String { - value - .split('_') - .filter(|part| !part.is_empty()) - .map(|part| { - let mut chars = part.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - first.to_ascii_uppercase().to_string() + chars.as_str() - }) - .collect::() + assert!(source.contains("Profile? previousData")); + assert!(source.contains("data(context, previousData)")); + assert!(!source.contains("Profile??")); + assert!(!source.contains("previousData as Profile?")); + } } diff --git a/crates/dust_state_plugin/src/plugin/emit/state_fields.rs b/crates/dust_state_plugin/src/plugin/emit/state_fields.rs deleted file mode 100644 index 7c86710e..00000000 --- a/crates/dust_state_plugin/src/plugin/emit/state_fields.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::collections::HashMap; - -use dust_dart_emit::DYNAMIC_TYPES; -use dust_ir::{DartFileIr, TypeIr}; -use dust_plugin_api::SymbolPlan; - -use crate::plugin::{constants::STATES_ANALYSIS_KEY, model::StateFact}; - -use super::StateFieldSpec; - -/// Loads state field facts produced by workspace analysis. -pub(super) fn state_facts(plan: &SymbolPlan) -> HashMap> { - plan.workspace_string_set(STATES_ANALYSIS_KEY) - .unwrap_or_default() - .iter() - .filter_map(|value| serde_json::from_str::(value).ok()) - .map(|fact| { - let fields = fact - .fields - .into_iter() - .map(|field| StateFieldSpec { - name: field.name, - type_source: field.type_source, - }) - .collect::>(); - (fact.class_name, fields) - }) - .collect() -} - -/// Returns state fields from the current library or workspace fallback facts. -pub(super) fn class_fields( - library: &DartFileIr, - state_facts: &HashMap>, - class_name: &str, -) -> Vec { - if let Some(class) = library - .classes - .iter() - .find(|candidate| candidate.name == class_name) - { - let fields = class - .fields - .iter() - .map(|field| StateFieldSpec { - name: field.name.clone(), - type_source: render_type(&field.ty), - }) - .collect::>(); - if !fields.is_empty() { - return fields; - } - } - state_facts - .get(class_name) - .filter(|fields| !fields.is_empty()) - .cloned() - .unwrap_or_default() -} - -/// Renders a state field type through the shared dynamic-safe type renderer. -fn render_type(ty: &TypeIr) -> String { - DYNAMIC_TYPES.render(ty) -} diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/aspect_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/aspect_class.jinja deleted file mode 100644 index e45b6f85..00000000 --- a/crates/dust_state_plugin/src/plugin/emit/templates/aspect_class.jinja +++ /dev/null @@ -1,9 +0,0 @@ -final class {{ aspect_class }} { - const {{ aspect_class }}(this.selector); - - final R Function({{ state_type }} state) selector; - - bool hasChanged({{ state_type }} previous, {{ state_type }} next) { - return selector(previous) != selector(next); - } -} diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/aspect_selector.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/aspect_selector.jinja deleted file mode 100644 index d92e0de6..00000000 --- a/crates/dust_state_plugin/src/plugin/emit/templates/aspect_selector.jinja +++ /dev/null @@ -1,4 +0,0 @@ -{{ field_type }} _{{ vm_lower }}Select{{ field_pascal }}({{ state_type }} state) => state.{{ field_name }}; -final _{{ vm_lower }}{{ field_pascal }}Aspect = {{ aspect_class }}<{{ field_type }}>( - _{{ vm_lower }}Select{{ field_pascal }}, -); diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/async_builder_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/async_builder_class.jinja new file mode 100644 index 00000000..27df9e0e --- /dev/null +++ b/crates/dust_state_plugin/src/plugin/emit/templates/async_builder_class.jinja @@ -0,0 +1,35 @@ +/// Builds the common loading, data, and error UI for {{ view_model_class }}. +class {{ builder_class }} extends StatelessWidget { + const {{ builder_class }}({ + super.key, + required this.loading, + required this.data, + required this.error, + }); + + final Widget Function(BuildContext context) loading; + final Widget Function(BuildContext context, {{ data_type }} data) data; + final Widget Function( + BuildContext context, + Object error, + {{ previous_data_type }} previousData, + ) error; + + @override + Widget build(BuildContext context) { + final state = context.watch{{ view_model_class }}().value; + return switch (state) { + AsyncData<{{ data_type }}>(data: final loadedData) => + data(context, loadedData), + AsyncLoading<{{ data_type }}>( + hasPreviousData: true, + previousData: final previousData, + ) => data(context, {{ previous_data_argument }}), + AsyncFailure<{{ data_type }}>( + error: final loadError, + previousData: final previousData, + ) => error(context, loadError, previousData), + _ => loading(context), + }; + } +} diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/base_async_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/base_async_class.jinja new file mode 100644 index 00000000..b78c1f82 --- /dev/null +++ b/crates/dust_state_plugin/src/plugin/emit/templates/base_async_class.jinja @@ -0,0 +1,17 @@ +/// Async base class generated for {{ view_model_class }}. +/// +/// Extend it from your ViewModel and implement `loadData`. +/// +/// ```dart +/// final class {{ view_model_class }} extends {{ generated_base }} { +/// {{ view_model_class }}(super.args); +/// +/// @override +/// Future<{{ data_type }}> loadData() { +/// return args.repository.load(); +/// } +/// } +/// ``` +abstract class {{ generated_base }} extends AsyncViewModelBase<{{ data_type }}, {{ args_type }}> { + {{ generated_base }}(super.args); +} diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/base_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/base_class.jinja index a79273c0..6bafc75e 100644 --- a/crates/dust_state_plugin/src/plugin/emit/templates/base_class.jinja +++ b/crates/dust_state_plugin/src/plugin/emit/templates/base_class.jinja @@ -1,6 +1,6 @@ -/// Generated base class for {{ view_model_class }}. +/// Base class generated for {{ view_model_class }}. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class {{ view_model_class }} extends {{ generated_base }} { diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/context_extension.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/context_extension.jinja index 1f98fe3b..e953d91f 100644 --- a/crates/dust_state_plugin/src/plugin/emit/templates/context_extension.jinja +++ b/crates/dust_state_plugin/src/plugin/emit/templates/context_extension.jinja @@ -1,4 +1,4 @@ -/// Generated BuildContext helpers for {{ view_model_class }}. +/// BuildContext helpers generated for {{ view_model_class }}. /// /// ```dart /// final vm = context.{{ read_name }}(); diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/inherited_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/inherited_class.jinja index dfdf1e58..b7e83989 100644 --- a/crates/dust_state_plugin/src/plugin/emit/templates/inherited_class.jinja +++ b/crates/dust_state_plugin/src/plugin/emit/templates/inherited_class.jinja @@ -1,25 +1,11 @@ -class {{ inherited_class }} extends InheritedModel<{{ aspect_class }}> { +class {{ inherited_class }} extends InheritedWidget { const {{ inherited_class }}({required this.viewModel, required this.state, required super.child}); final {{ view_model_class }} viewModel; final {{ state_type }} state; - /// Requires {{ state_type }} to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify({{ inherited_class }} oldWidget) => state != oldWidget.state; - - @override - bool updateShouldNotifyDependent( - {{ inherited_class }} oldWidget, - Set<{{ aspect_class }}> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify({{ inherited_class }} oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/instance_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/instance_class.jinja new file mode 100644 index 00000000..361da99a --- /dev/null +++ b/crates/dust_state_plugin/src/plugin/emit/templates/instance_class.jinja @@ -0,0 +1,10 @@ +class {{ instance_class }} extends InheritedWidget { + const {{ instance_class }}({required this.viewModel, required super.child}); + + final {{ view_model_class }} viewModel; + + @override + bool updateShouldNotify({{ instance_class }} oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/listener_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/listener_class.jinja index 7b9bcd2e..e63b310b 100644 --- a/crates/dust_state_plugin/src/plugin/emit/templates/listener_class.jinja +++ b/crates/dust_state_plugin/src/plugin/emit/templates/listener_class.jinja @@ -1,4 +1,4 @@ -/// Listens to one-shot effects from {{ view_model_class }}. +/// Handles one-shot effects from {{ view_model_class }}. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -25,7 +25,7 @@ class {{ listener_state_class }} extends State<{{ listener_class }}> { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = {{ scope_class }}.read(context); + final nextViewModel = {{ scope_class }}._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/proxy_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/proxy_class.jinja index f34c92a1..b36fd45c 100644 --- a/crates/dust_state_plugin/src/plugin/emit/templates/proxy_class.jinja +++ b/crates/dust_state_plugin/src/plugin/emit/templates/proxy_class.jinja @@ -1,11 +1,9 @@ -/// Typed state reader returned by `context.watch{{ view_model_class }}()`. +/// Reads {{ view_model_class }} state from `BuildContext`. /// -/// Read `value` to rebuild for the whole state, or call `select` to rebuild only -/// when the selected value changes. +/// Read `value` when the widget should rebuild for any state change. /// /// ```dart /// final state = context.watch{{ view_model_class }}().value; -/// final count = context.watch{{ view_model_class }}().select((state) => state.count); /// ``` class {{ proxy_class }} { {{ proxy_class }}(this._context); @@ -14,10 +12,5 @@ class {{ proxy_class }} { {{ state_type }} get value { return {{ scope_class }}.of(_context).value; - }{{ field_getters }} - - R select(R Function({{ state_type }} state) selector) { - final aspect = {{ aspect_class }}(selector); - return selector({{ scope_class }}.of(_context, aspect: aspect).value); } } diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/proxy_field_getter.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/proxy_field_getter.jinja deleted file mode 100644 index db57caf3..00000000 --- a/crates/dust_state_plugin/src/plugin/emit/templates/proxy_field_getter.jinja +++ /dev/null @@ -1,8 +0,0 @@ - - - {{ field_type }} get {{ field_name }} { - return {{ scope_class }}.of( - _context, - aspect: _{{ vm_lower }}{{ field_pascal }}Aspect, - ).state.{{ field_name }}; - } diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/scope_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/scope_class.jinja index db8da1a0..3a24e175 100644 --- a/crates/dust_state_plugin/src/plugin/emit/templates/scope_class.jinja +++ b/crates/dust_state_plugin/src/plugin/emit/templates/scope_class.jinja @@ -1,7 +1,7 @@ -/// Provides {{ view_model_class }} to descendants and owns it by default. +/// Creates and provides {{ view_model_class }} to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// {{ scope_class }}( @@ -11,12 +11,13 @@ /// ) /// ``` class {{ scope_class }} extends StatefulWidget { - /// Creates an owned {{ view_model_class }} from typed args. + /// Creates and owns {{ view_model_class }} from typed args. const {{ scope_class }}({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned {{ view_model_class }} without disposing it. @@ -25,27 +26,33 @@ class {{ scope_class }} extends StatefulWidget { required {{ view_model_class }} this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final {{ args_type }} Function(BuildContext context)? args; final {{ view_model_class }} Function(BuildContext context, {{ args_type }} args)? create; + final Object? Function(BuildContext context)? identity; final {{ view_model_class }}? value; final Widget child; - /// Reads {{ view_model_class }} without subscribing the caller to state changes. + /// Reads {{ view_model_class }} without rebuilding when state changes. static {{ view_model_class }} read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<{{ inherited_class }}>() - ?.widget as {{ inherited_class }}?; + .getElementForInheritedWidgetOfExactType<{{ instance_class }}>() + ?.widget as {{ instance_class }}?; if (scope == null) throw StateError('No {{ scope_class }} found in context.'); return scope.viewModel; } - /// Watches {{ view_model_class }} and optionally subscribes to one generated aspect. - static {{ view_model_class }} of(BuildContext context, { {{- aspect_class }}? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<{{ inherited_class }}>( - aspect: aspect, - ); + static {{ view_model_class }} _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<{{ instance_class }}>(); + if (scope == null) throw StateError('No {{ scope_class }} found in context.'); + return scope.viewModel; + } + + /// Watches {{ view_model_class }} and rebuilds when state changes. + static {{ view_model_class }} of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<{{ inherited_class }}>(); if (scope == null) throw StateError('No {{ scope_class }} found in context.'); return scope.viewModel; } @@ -56,13 +63,21 @@ class {{ scope_class }} extends StatefulWidget { class _{{ scope_class }}State extends State<{{ scope_class }}> { {{ view_model_class }}? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -73,14 +88,17 @@ class _{{ scope_class }}State extends State<{{ scope_class }}> { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - {{ view_model_class }} _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - {{ view_model_class }} _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -110,6 +128,7 @@ class _{{ scope_class }}State extends State<{{ scope_class }}> { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -118,16 +137,28 @@ class _{{ scope_class }}State extends State<{{ scope_class }}> { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit({{ view_model_class }} viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('{{ scope_class }} init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -146,10 +177,13 @@ class _{{ scope_class }}State extends State<{{ scope_class }}> { if (viewModel == null) { throw StateError('{{ scope_class }} built before its view model was initialized.'); } - return {{ inherited_class }}( + return {{ instance_class }}( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: {{ inherited_class }}( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/selector_class.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/selector_class.jinja new file mode 100644 index 00000000..fd942757 --- /dev/null +++ b/crates/dust_state_plugin/src/plugin/emit/templates/selector_class.jinja @@ -0,0 +1,85 @@ +/// Rebuilds when a selected {{ state_type }} value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class {{ selector_class }} extends StatefulWidget { + const {{ selector_class }}({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function({{ state_type }} state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State<{{ selector_class }}> createState() => {{ selector_state_class }}(); +} + +class {{ selector_state_class }} extends State<{{ selector_class }}> { + {{ view_model_class }}? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = {{ scope_class }}._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget({{ selector_class }} oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } + + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); + } +} diff --git a/crates/dust_state_plugin/src/plugin/emit/templates/view_model_output.jinja b/crates/dust_state_plugin/src/plugin/emit/templates/view_model_output.jinja deleted file mode 100644 index 63b432ee..00000000 --- a/crates/dust_state_plugin/src/plugin/emit/templates/view_model_output.jinja +++ /dev/null @@ -1,13 +0,0 @@ -{{ aspect }} - -{{ base }} - -{{ proxy }} - -{{ scope }} - -{{ inherited }} - -{{ listener }} - -{{ extension }} diff --git a/crates/dust_state_plugin/src/plugin/model.rs b/crates/dust_state_plugin/src/plugin/model.rs index 3f68954b..5014664c 100644 --- a/crates/dust_state_plugin/src/plugin/model.rs +++ b/crates/dust_state_plugin/src/plugin/model.rs @@ -1,5 +1,15 @@ use serde::{Deserialize, Serialize}; +/// Generated ViewModel base mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ViewModelMode { + /// Synchronous state managed directly by the ViewModel. + Sync, + /// Async loaded data wrapped in generated lifecycle state. + Async, +} + /// Workspace fact describing a view model class discovered during parsing. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct ViewModelFact { @@ -11,6 +21,8 @@ pub(crate) struct ViewModelFact { pub(crate) args_type: Option, /// Optional Dart expression source used as the initial state value. pub(crate) initial_source: Option, + /// Generated ViewModel base mode. + pub(crate) mode: ViewModelMode, /// Name of the generated abstract base class the view model must extend. pub(crate) generated_base_class: String, /// Import URI that makes the view model visible from other libraries. @@ -26,6 +38,10 @@ pub(crate) struct ViewModelAnnotation { pub(crate) args_type: Option, /// Optional Dart expression named by the `initial` argument. pub(crate) initial_source: Option, + /// Raw mode expression source, if supplied. + pub(crate) mode_source: Option, + /// Generated ViewModel base mode. + pub(crate) mode: ViewModelMode, } /// Workspace fact describing a state class and the fields available to selectors. diff --git a/crates/dust_state_plugin/src/plugin/parse.rs b/crates/dust_state_plugin/src/plugin/parse.rs index bb3194d0..94e81347 100644 --- a/crates/dust_state_plugin/src/plugin/parse.rs +++ b/crates/dust_state_plugin/src/plugin/parse.rs @@ -6,7 +6,10 @@ use dust_ir::SpanIr; #[cfg(test)] use dust_text::{FileId, TextRange}; -use super::{constants::VIEW_MODEL, model::ViewModelAnnotation}; +use super::{ + constants::VIEW_MODEL, + model::{ViewModelAnnotation, ViewModelMode}, +}; /// Finds the first `@ViewModel` config application in a class config list. pub(crate) fn view_model_config(configs: &[ConfigApplicationIr]) -> Option<&ConfigApplicationIr> { @@ -28,10 +31,14 @@ pub(crate) fn parse_view_model_config(config: &ConfigApplicationIr) -> Option) -> ViewModelMode { + match source.map(str::trim) { + Some(source) if source.ends_with(".async") || source == "async" => ViewModelMode::Async, + _ => ViewModelMode::Sync, + } +} + +/// Returns whether an annotation expression is a supported ViewModel mode. +pub(crate) fn is_valid_mode_source(source: &str) -> bool { + let source = source.trim(); + source == "sync" || source == "async" || source.ends_with(".sync") || source.ends_with(".async") +} + /// Returns the final unqualified segment of a config symbol identifier. fn config_name(symbol: &SymbolId) -> &str { symbol.0.rsplit("::").next().unwrap_or(symbol.0.as_str()) @@ -78,6 +103,18 @@ mod tests { assert_eq!(annotation.state_type, "TaskBoardState"); assert_eq!(annotation.args_type.as_deref(), Some("TaskBoardArgs")); assert_eq!(annotation.initial_source, None); + assert_eq!(annotation.mode, super::ViewModelMode::Sync); + } + + #[test] + fn parses_async_mode() { + let annotation = parse_view_model_annotation(Some( + "(state: Profile, args: ProfileArgs, mode: ViewModelMode.async)", + )) + .unwrap(); + assert_eq!(annotation.state_type, "Profile"); + assert_eq!(annotation.args_type.as_deref(), Some("ProfileArgs")); + assert_eq!(annotation.mode, super::ViewModelMode::Async); } } diff --git a/crates/dust_state_plugin/src/plugin/validate.rs b/crates/dust_state_plugin/src/plugin/validate.rs index c597c5bb..1db46731 100644 --- a/crates/dust_state_plugin/src/plugin/validate.rs +++ b/crates/dust_state_plugin/src/plugin/validate.rs @@ -3,7 +3,10 @@ use std::collections::HashSet; use dust_diagnostics::Diagnostic; use dust_ir::{ClassIr, DartFileIr}; -use super::parse::{parse_view_model_config, view_model_config}; +use super::{ + model::ViewModelMode, + parse::{is_valid_mode_source, parse_view_model_config, view_model_config}, +}; /// Validates state plugin annotations and generated-base-class contracts. pub(crate) fn validate_library_state(library: &DartFileIr) -> Vec { @@ -39,6 +42,15 @@ pub(crate) fn validate_library_state(library: &DartFileIr) -> Vec { ))); } + if let Some(mode_source) = annotation.mode_source.as_deref() + && !is_valid_mode_source(mode_source) + { + diagnostics.push(Diagnostic::error(format!( + "view model `{}` mode `{mode_source}` must be `ViewModelMode.sync` or `ViewModelMode.async`", + class.name + ))); + } + if !local_classes.contains(annotation.state_type.as_str()) && !local_enums.contains(annotation.state_type.as_str()) && !has_imports(library) @@ -49,7 +61,15 @@ pub(crate) fn validate_library_state(library: &DartFileIr) -> Vec { ))); } - if annotation.initial_source.is_none() + if annotation.mode == ViewModelMode::Async && annotation.initial_source.is_some() { + diagnostics.push(Diagnostic::error(format!( + "view model `{}` uses async mode, so remove `initial:`; Dust owns AsyncState initialization", + class.name + ))); + } + + if annotation.mode == ViewModelMode::Sync + && annotation.initial_source.is_none() && local_enums.contains(annotation.state_type.as_str()) { diagnostics.push(Diagnostic::error(format!( @@ -58,10 +78,11 @@ pub(crate) fn validate_library_state(library: &DartFileIr) -> Vec { ))); } - if let Some(state_class) = library - .classes - .iter() - .find(|candidate| candidate.name == annotation.state_type) + if annotation.mode == ViewModelMode::Sync + && let Some(state_class) = library + .classes + .iter() + .find(|candidate| candidate.name == annotation.state_type) && annotation.initial_source.is_none() { validate_default_initial_state(class, state_class, &mut diagnostics); diff --git a/crates/dust_state_plugin/tests/state_plugin_tests/analysis.rs b/crates/dust_state_plugin/tests/state_plugin_tests/analysis.rs index 3678ca6b..741dfb3c 100644 --- a/crates/dust_state_plugin/tests/state_plugin_tests/analysis.rs +++ b/crates/dust_state_plugin/tests/state_plugin_tests/analysis.rs @@ -137,7 +137,7 @@ fn collects_state_and_view_model_workspace_facts() { let view_models = snapshot.string_set("dust_state.view_models.v1").unwrap(); assert_eq!( view_models, - &[r#"{"class_name":"TaskBoardViewModel","state_type":"TaskBoardState","args_type":"TaskBoardArgs","initial_source":"const TaskBoardState()","generated_base_class":"$TaskBoardViewModel","import_uri":"package:state_test/features/task_board.dart"}"#.to_owned()] + &[r#"{"class_name":"TaskBoardViewModel","state_type":"TaskBoardState","args_type":"TaskBoardArgs","initial_source":"const TaskBoardState()","mode":"sync","generated_base_class":"$TaskBoardViewModel","import_uri":"package:state_test/features/task_board.dart"}"#.to_owned()] ); } @@ -167,7 +167,7 @@ fn collects_prefixed_view_model_workspace_fact() { assert_eq!( snapshot.string_set("dust_state.view_models.v1").unwrap(), - &[r#"{"class_name":"TaskBoardViewModel","state_type":"TaskBoardState","args_type":"TaskBoardArgs","initial_source":"const TaskBoardState()","generated_base_class":"$TaskBoardViewModel","import_uri":"package:state_test/features/task_board.dart"}"#.to_owned()] + &[r#"{"class_name":"TaskBoardViewModel","state_type":"TaskBoardState","args_type":"TaskBoardArgs","initial_source":"const TaskBoardState()","mode":"sync","generated_base_class":"$TaskBoardViewModel","import_uri":"package:state_test/features/task_board.dart"}"#.to_owned()] ); } diff --git a/crates/dust_state_plugin/tests/state_plugin_tests/emission/base.rs b/crates/dust_state_plugin/tests/state_plugin_tests/emission/base.rs index 97752d73..fc0d93a4 100644 --- a/crates/dust_state_plugin/tests/state_plugin_tests/emission/base.rs +++ b/crates/dust_state_plugin/tests/state_plugin_tests/emission/base.rs @@ -29,15 +29,28 @@ fn emits_generated_base_with_args_getters() { assert!(!source.contains("get repository => args.repository")); assert!(source.contains("class TaskBoardViewModelScope extends StatefulWidget")); assert!(source.contains("void didUpdateWidget(TaskBoardViewModelScope oldWidget)")); - assert!(source.contains("scheduleMicrotask(() {")); + assert!(source.contains("this.identity")); + assert!(source.contains("final Object? Function(BuildContext context)? identity;")); + assert!(source.contains("class TaskBoardViewModelSelector extends StatefulWidget")); + assert!(source.contains("final R Function(TaskBoardState state) selector;")); + assert!(source.contains("final bool Function(R previous, R next)? equals;")); + assert!( + source.contains("final nextViewModel = TaskBoardViewModelScope._watchInstance(context);") + ); + assert!(source.contains("class _TaskBoardViewModelInstance extends InheritedWidget")); + assert!(source.contains("scheduleMicrotask(() async {")); + assert!(source.contains("await viewModel.init();")); + assert!(source.contains("FlutterError.reportError(")); + assert!(source.contains("ErrorDescription('TaskBoardViewModelScope init failed')")); assert!(!source.contains("ViewModelOwner<")); assert!(!source.contains("ListenableBuilder(")); + assert!(!source.contains("if (ownsViewModel) {")); assert!(source.contains("class TaskBoardViewModelListener extends StatefulWidget")); assert_eq!( extract_doc_before(source, "abstract class $TaskBoardViewModel"), - r#"/// Generated base class for TaskBoardViewModel. + r#"/// Base class generated for TaskBoardViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class TaskBoardViewModel extends $TaskBoardViewModel { @@ -47,22 +60,20 @@ fn emits_generated_base_with_args_getters() { ); assert_eq!( extract_doc_before(source, "class _$TaskBoardViewModelProxy"), - r#"/// Typed state reader returned by `context.watchTaskBoardViewModel()`. + r#"/// Reads TaskBoardViewModel state from `BuildContext`. /// -/// Read `value` to rebuild for the whole state, or call `select` to rebuild only -/// when the selected value changes. +/// Read `value` when the widget should rebuild for any state change. /// /// ```dart /// final state = context.watchTaskBoardViewModel().value; -/// final count = context.watchTaskBoardViewModel().select((state) => state.count); /// ```"# ); assert_eq!( extract_doc_before(source, "class TaskBoardViewModelScope"), - r#"/// Provides TaskBoardViewModel to descendants and owns it by default. + r#"/// Creates and provides TaskBoardViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// TaskBoardViewModelScope( @@ -74,7 +85,7 @@ fn emits_generated_base_with_args_getters() { ); assert_eq!( extract_doc_before(source, "const TaskBoardViewModelScope({"), - r#" /// Creates an owned TaskBoardViewModel from typed args."# + r#" /// Creates and owns TaskBoardViewModel from typed args."# ); assert_eq!( extract_doc_before(source, "const TaskBoardViewModelScope.value({"), @@ -82,15 +93,15 @@ fn emits_generated_base_with_args_getters() { ); assert_eq!( extract_doc_before(source, "static TaskBoardViewModel read"), - r#" /// Reads TaskBoardViewModel without subscribing the caller to state changes."# + r#" /// Reads TaskBoardViewModel without rebuilding when state changes."# ); assert_eq!( extract_doc_before(source, "static TaskBoardViewModel of"), - r#" /// Watches TaskBoardViewModel and optionally subscribes to one generated aspect."# + r#" /// Watches TaskBoardViewModel and rebuilds when state changes."# ); assert_eq!( extract_doc_before(source, "class TaskBoardViewModelListener"), - r#"/// Listens to one-shot effects from TaskBoardViewModel. + r#"/// Handles one-shot effects from TaskBoardViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -103,7 +114,7 @@ fn emits_generated_base_with_args_getters() { ); assert_eq!( extract_doc_before(source, "extension TaskBoardViewModelBuildContext"), - r#"/// Generated BuildContext helpers for TaskBoardViewModel. + r#"/// BuildContext helpers generated for TaskBoardViewModel. /// /// ```dart /// final vm = context.readTaskBoardViewModel(); @@ -142,6 +153,34 @@ fn emits_explicit_initial_expression() { ); } +#[test] +fn emits_async_view_model_base() { + let plugin = register_plugin(); + let contribution = plugin.emit( + &library_with_classes(vec![ + args_class(), + view_model_class( + "ProfileViewModel", + "(state: Profile, args: TaskBoardArgs, mode: ViewModelMode.async)", + ), + ]), + &SymbolPlan::default(), + ); + + let source = &contribution.support_types[0]; + assert!(source.contains("abstract class $ProfileViewModel")); + assert!(source.contains("extends AsyncViewModelBase")); + assert!(source.contains("$ProfileViewModel(super.args);")); + assert!(source.contains("AsyncState get value")); + assert!(source.contains("final R Function(AsyncState state) selector;")); + assert!(source.contains("class ProfileViewModelBuilder extends StatelessWidget")); + assert!(source.contains("final Widget Function(BuildContext context, Profile data) data;")); + assert!(source.contains("AsyncLoading(")); + assert!(source.contains("hasPreviousData: true")); + assert!(!source.contains("const Profile()")); + assert!(!source.contains("initialState:")); +} + #[test] fn emits_value_only_proxy_for_fieldless_state() { let plugin = register_plugin(); @@ -160,6 +199,8 @@ fn emits_value_only_proxy_for_fieldless_state() { let source = &contribution.support_types[0]; assert!(!source.contains("enum _TaskBoardViewModelAspect")); assert!(source.contains("TaskBoardState get value")); + assert!(!source.contains("select")); + assert!(!source.contains("int get count")); assert_eq!( extract_extension(source, "extension TaskBoardViewModelBuildContext"), r#"extension TaskBoardViewModelBuildContext on BuildContext { diff --git a/crates/dust_state_plugin/tests/state_plugin_tests/emission/imports.rs b/crates/dust_state_plugin/tests/state_plugin_tests/emission/imports.rs index 44c62dd6..a5f80096 100644 --- a/crates/dust_state_plugin/tests/state_plugin_tests/emission/imports.rs +++ b/crates/dust_state_plugin/tests/state_plugin_tests/emission/imports.rs @@ -6,7 +6,7 @@ use dust_state_plugin::register_plugin; use crate::support::{args_class, library_with_classes, view_model_class}; #[test] -fn emits_state_fields_from_workspace_analysis_for_imported_state() { +fn imported_state_fields_do_not_generate_proxy_getters() { let plugin = register_plugin(); let mut builder = WorkspaceAnalysisBuilder::default(); builder.add_string_set_value( @@ -31,17 +31,19 @@ fn emits_state_fields_from_workspace_analysis_for_imported_state() { let contribution = plugin.emit(&library, &plan); let source = &contribution.support_types[0]; - assert!(source.contains("final class _ProductsViewModelAspect")); - assert!(source.contains("final _productsViewModelProductsAspect")); - assert!(source.contains("final _productsViewModelStatusAspect")); - assert!(source.contains("final _productsViewModelErrorMessageAspect")); - assert!(source.contains("List get products")); - assert!(source.contains("ProductsStatus get status")); - assert!(source.contains("String? get errorMessage")); + assert!(!source.contains("_ProductsViewModelAspect")); + assert!(!source.contains("_productsViewModelProductsAspect")); + assert!(!source.contains("_productsViewModelStatusAspect")); + assert!(!source.contains("_productsViewModelErrorMessageAspect")); + assert!(!source.contains("List get products")); + assert!(!source.contains("ProductsStatus get status")); + assert!(!source.contains("String? get errorMessage")); + assert!(!source.contains("select")); + assert!(source.contains("ProductsState get value")); } #[test] -fn workspace_state_facts_are_the_only_imported_state_field_source() { +fn workspace_state_facts_cannot_leak_imported_field_types() { let plugin = register_plugin(); let mut builder = WorkspaceAnalysisBuilder::default(); builder.add_string_set_value( @@ -66,10 +68,11 @@ fn workspace_state_facts_are_the_only_imported_state_field_source() { let contribution = plugin.emit(&library, &plan); let source = &contribution.support_types[0]; - assert!(source.contains("final class _ProductsViewModelAspect")); - assert!(source.contains("final _productsViewModelProductsAspect")); - assert!(source.contains("final _productsViewModelStatusAspect")); - assert!(source.contains("List get products")); - assert!(source.contains("ProductsStatus get status")); + assert!(!source.contains("_ProductsViewModelAspect")); + assert!(!source.contains("_productsViewModelProductsAspect")); + assert!(!source.contains("_productsViewModelStatusAspect")); + assert!(!source.contains("List get products")); + assert!(!source.contains("ProductsStatus get status")); assert!(!source.contains("List get products")); + assert!(source.contains("ProductsState get value")); } diff --git a/crates/dust_state_plugin/tests/state_plugin_tests/emission/workspace.rs b/crates/dust_state_plugin/tests/state_plugin_tests/emission/workspace.rs index 755a57b3..68f5c763 100644 --- a/crates/dust_state_plugin/tests/state_plugin_tests/emission/workspace.rs +++ b/crates/dust_state_plugin/tests/state_plugin_tests/emission/workspace.rs @@ -7,7 +7,7 @@ use super::support::extract_class; use crate::support::{args_class, library_with_classes, view_model_class}; #[test] -fn emits_state_fields_from_workspace_analysis() { +fn ignores_state_fields_from_workspace_analysis() { let plugin = register_plugin(); let mut builder = WorkspaceAnalysisBuilder::default(); builder.add_string_set_value( @@ -33,9 +33,9 @@ fn emits_state_fields_from_workspace_analysis() { ); let source = &contribution.support_types[0]; - assert!(source.contains("final class _TaskBoardViewModelAspect")); - assert!(source.contains("final _taskBoardViewModelCountAspect")); - assert!(source.contains("final _taskBoardViewModelMessageAspect")); + assert!(!source.contains("_TaskBoardViewModelAspect")); + assert!(!source.contains("_taskBoardViewModelCountAspect")); + assert!(!source.contains("_taskBoardViewModelMessageAspect")); assert!(!source.contains("get count => state.count")); assert!(!source.contains("get message => state.message")); assert!(!source.contains("get repository => args.repository")); @@ -50,59 +50,26 @@ fn emits_state_fields_from_workspace_analysis() { TaskBoardState get value { return TaskBoardViewModelScope.of(_context).value; } - - int get count { - return TaskBoardViewModelScope.of( - _context, - aspect: _taskBoardViewModelCountAspect, - ).state.count; - } - - String? get message { - return TaskBoardViewModelScope.of( - _context, - aspect: _taskBoardViewModelMessageAspect, - ).state.message; - } - - R select(R Function(TaskBoardState state) selector) { - final aspect = _TaskBoardViewModelAspect(selector); - return selector(TaskBoardViewModelScope.of(_context, aspect: aspect).value); - } }"# ); assert_eq!( extract_class(source, "class _TaskBoardViewModelInherited"), - r#"class _TaskBoardViewModelInherited extends InheritedModel<_TaskBoardViewModelAspect> { + r#"class _TaskBoardViewModelInherited extends InheritedWidget { const _TaskBoardViewModelInherited({required this.viewModel, required this.state, required super.child}); final TaskBoardViewModel viewModel; final TaskBoardState state; - /// Requires TaskBoardState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. - @override - bool updateShouldNotify(_TaskBoardViewModelInherited oldWidget) => state != oldWidget.state; - @override - bool updateShouldNotifyDependent( - _TaskBoardViewModelInherited oldWidget, - Set<_TaskBoardViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_TaskBoardViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } }"# ); } #[test] -fn emits_many_state_fields_without_base_getter_import_leaks() { +fn ignores_many_state_fields_without_import_leaks() { let plugin = register_plugin(); let fields = (0..120) .map(|index| format!(r#"{{"name":"field{index}","type_source":"int"}}"#)) @@ -128,8 +95,9 @@ fn emits_many_state_fields_without_base_getter_import_leaks() { ); let source = &contribution.support_types[0]; - assert!(source.contains("final _taskBoardViewModelField119Aspect")); - assert!(source.contains("int get field119 {")); + assert!(!source.contains("_taskBoardViewModelField119Aspect")); + assert!(!source.contains("int get field119 {")); + assert!(!source.contains("R select")); assert!(!source.contains("int get field119 => state.field119;")); assert!(!source.contains("get repository => args.repository")); } diff --git a/crates/dust_state_plugin/tests/state_plugin_tests/validation.rs b/crates/dust_state_plugin/tests/state_plugin_tests/validation.rs index f2226b59..8500b2a2 100644 --- a/crates/dust_state_plugin/tests/state_plugin_tests/validation.rs +++ b/crates/dust_state_plugin/tests/state_plugin_tests/validation.rs @@ -196,3 +196,66 @@ fn accepts_imported_state_and_args_when_library_has_imports() { assert!(diagnostics.is_empty(), "{diagnostics:?}"); } + +#[test] +fn accepts_async_state_without_default_constructor() { + let plugin = register_plugin(); + let mut state = state_class(); + state.fields = vec![FieldIr { + name: "name".to_owned(), + ty: TypeIr::named("String"), + span: super::support::span(20, 30), + has_default: false, + serde: None, + configs: Vec::new(), + }]; + + let diagnostics = plugin.validate(&library_with_classes(vec![ + state, + args_class(), + view_model_class( + "ProfileViewModel", + "(state: TaskBoardState, args: TaskBoardArgs, mode: ViewModelMode.async)", + ), + ])); + + assert!(diagnostics.is_empty(), "{diagnostics:?}"); +} + +#[test] +fn rejects_async_initial_expression() { + let plugin = register_plugin(); + let diagnostics = plugin.validate(&library_with_classes(vec![ + state_class(), + args_class(), + view_model_class( + "ProfileViewModel", + "(state: TaskBoardState, args: TaskBoardArgs, mode: ViewModelMode.async, initial: const TaskBoardState())", + ), + ])); + + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("uses async mode, so remove `initial:`") + })); +} + +#[test] +fn rejects_unknown_view_model_mode() { + let plugin = register_plugin(); + let diagnostics = plugin.validate(&library_with_classes(vec![ + state_class(), + args_class(), + view_model_class( + "TaskBoardViewModel", + "(state: TaskBoardState, args: TaskBoardArgs, mode: FancyMode.fast)", + ), + ])); + + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("must be `ViewModelMode.sync` or `ViewModelMode.async`") + })); +} diff --git a/docs/README.md b/docs/README.md index 55dbc3aa..75641c98 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ These pages are backed by the runnable example package in - [Developer guide](./developer.md) - [Plugin guide](./plugin-guide.md) +- [State management design](./state-management-design.md) - [Release runbook](./release-0.1.0.md) - [Roadmap and milestones](https://github.com/y3l1n4ung/dust/milestones) diff --git a/docs/state-management-design.md b/docs/state-management-design.md new file mode 100644 index 00000000..bbac35be --- /dev/null +++ b/docs/state-management-design.md @@ -0,0 +1,122 @@ +# State Management Hardening Notes + +This page records the current Dust state-management design and the hardening +rules that protect it. User-facing usage lives in +[usage/state.md](./usage/state.md). + +## Current Shape + +Dust has one annotation: + +```dart +@ViewModel(state: CounterState) +@ViewModel(state: HomePageData, mode: ViewModelMode.async) +``` + +Sync ViewModels extend generated `ViewModelBase` support. Async +ViewModels extend generated `AsyncViewModelBase` support and expose +`AsyncState` to the widget tree. + +## Design Rules + +- Dependencies go through typed `args`. +- UI state goes through `state`, `value`, or generated selector widgets. +- Generated proxies expose `.value` only. +- Do not generate mirror getters for `state.*` or `args.*`. +- Do not expose closure-backed `watch().select(...)`. +- Use generated selector widgets for selected-value rebuilds. +- Use generated listeners for one-shot effects; listener children must not + rebuild for effects. +- Use scope `identity` when an owned ViewModel must be recreated for route id, + user id, tenant, repository, or workspace changes. +- `invalidateSelf()` resets sync state to the generated initial state and async + state to `AsyncInitial`. + +## Async Contract + +Async ViewModels implement one method: + +```dart +@override +Future loadData(); +``` + +The generated/runtime API owns lifecycle state: + +- `load()` starts fresh loading without preserving visible data. +- `refresh()` preserves previous visible data while loading. +- `retry()` aliases `refresh()`. +- `invalidateSelf()` clears pending work and visible data. +- stale async results are ignored by action tokens. +- nullable loaded data is tracked with `hasData` and `hasPreviousData`, not by + testing `data != null`. + +The public lifecycle states are: + +```dart +AsyncInitial +AsyncLoading +AsyncData +AsyncFailure +``` + +`AsyncLoading` and `AsyncFailure` can carry previous data. There is no +separate public `refreshing` state class; refresh is `AsyncLoading` with +`hasPreviousData == true`. + +## Generated UI Helpers + +Every ViewModel gets: + +- generated base class +- generated scope +- generated `watch...().value` and `read...()` context helpers +- generated selector widget +- generated listener widget + +Async ViewModels also get a generated builder: + +```dart +HomeViewModelBuilder( + loading: (_) => const CircularProgressIndicator(), + data: (context, data) => HomeContent(data: data), + error: (context, error, previousData) => HomeErrorView( + error: error, + previousData: previousData, + ), +) +``` + +Raw `switch` on `AsyncState` stays available for UIs that need exact +lifecycle control. + +## Fixtures And Gates + +The state-management hardening stack is protected by: + +- runtime async tests in `packages/dust_flutter/test/async_state_test.dart` +- sync invalidation tests in `packages/dust_flutter/test/view_model_test.dart` +- generated selector/listener widget tests in + `examples/shopping_app/test/state_selector_test.dart` +- generated scope lifecycle tests in + `examples/shopping_app/test/state_scope_lifecycle_test.dart` +- generated async builder widget tests in + `examples/shopping_app/test/app_view_model_test.dart` +- exact generated async output snapshot in + `crates/dust_driver/tests/driver_tests/state_outputs/snapshots/async_profile_view_model.dart.snapshot` +- driver output tests in + `crates/dust_driver/tests/driver_tests/state_outputs.rs` +- plugin emission and validation tests in `crates/dust_state_plugin/tests` + +Minimum local validation for state changes: + +```sh +cargo fmt --all -- --check +cargo test -p dust_state_plugin --test state_plugin_tests +cargo test -p dust_driver --test driver_tests state_outputs::build_writes_async_state_output_for_view_model_library -- --exact +(cd packages/dust_flutter && flutter test test/async_state_test.dart test/view_model_test.dart) +(cd examples/shopping_app && flutter analyze --no-pub) +(cd examples/shopping_app && flutter test test/state_selector_test.dart test/state_scope_lifecycle_test.dart test/app_view_model_test.dart) +target/debug/dust check --root examples/shopping_app --fail-fast +target/debug/dust check --root examples/shopping_app --db --fail-fast +``` diff --git a/docs/usage/state.md b/docs/usage/state.md index 20c518b0..1538e70d 100644 --- a/docs/usage/state.md +++ b/docs/usage/state.md @@ -1,97 +1,83 @@ # State Management -Dust provides state management generated from annotations. It uses explicit typed arguments for dependencies and provides convenient scoping and access via BuildContext. - ---- +Dust generates typed Flutter ViewModel glue from `@ViewModel`. +Dependencies go through `args`; UI state goes through `state`, `value`, or +generated selector widgets. ## Installation -Add the state management package to your `pubspec.yaml`: - ```yaml dependencies: + dust_dart: ^0.1.0 dust_flutter: ^0.1.0 ``` ---- - -## Basic Example - -### 1. Define the ViewModel -Annotate your class with `@ViewModel` and extend the generated base class. +## Sync ViewModel ```dart +import 'package:dust_dart/derive.dart'; import 'package:dust_flutter/state.dart'; part 'counter_view_model.g.dart'; +@Derive([ToString(), Eq(), CopyWith()]) +class CounterState with _$CounterState { + const CounterState({this.count = 0}); + + final int count; +} + @ViewModel(state: CounterState) class CounterViewModel extends $CounterViewModel { CounterViewModel(super.args); void increment() { - // Access state via the 'state' property emit(state.copyWith(count: state.count + 1)); } -} - -class CounterState { - const CounterState({this.count = 0}); - final int count; - CounterState copyWith({int? count}) => CounterState(count: count ?? this.count); + void reset() { + invalidateSelf(); + } } ``` -### 2. Provide the Scope +Provide the generated scope: + ```dart CounterViewModelScope( - args: (context) => const EmptyArgs(), - create: (context, args) => CounterViewModel(args), + args: (_) => const ViewModelArgs(), + create: (_, args) => CounterViewModel(args), child: const CounterPage(), ) ``` -### 3. Consume in UI +Use the generated context helpers: + ```dart class CounterPage extends StatelessWidget { + const CounterPage({super.key}); + @override Widget build(BuildContext context) { - // Access state via .value final count = context.watchCounterViewModel().value.count; - return Scaffold( - body: Center(child: Text('Count: $count')), - floatingActionButton: FloatingActionButton( - onPressed: () => context.readCounterViewModel().increment(), - child: const Icon(Icons.add), - ), + return TextButton( + onPressed: context.readCounterViewModel().increment, + child: Text('Count: $count'), ); } } ``` ---- +## Args -## Configuration Reference - -### `@ViewModel` Options - -| Property | Type | Description | -| :--- | :--- | :--- | -| `state` | `Type` | **Required.** The class used for the ViewModel's state. | -| `args` | `Type` | Optional: A custom class (extending `ViewModelArgs`) for dependency injection. | -| `initial` | `Object` | Optional: The initial state source code. Defaults to `const State()`. | - ---- - -## Dependency Injection (`ViewModelArgs`) - -For complex dependencies (Repositories, Services), use a custom `args` class. +Use `ViewModelArgs` for repositories, services, HTTP clients, sockets, storage, +and observers. Do not generate mirror getters for dependencies. ```dart final class ProfileArgs extends ViewModelArgs { - const ProfileArgs({required this.repository}); + const ProfileArgs({required this.repository, super.observer}); + final ProfileRepository repository; } @@ -99,104 +85,172 @@ final class ProfileArgs extends ViewModelArgs { class ProfileViewModel extends $ProfileViewModel { ProfileViewModel(super.args); - // Access dependencies via the 'args' property - void load() => args.repository.fetch(); + Future save() { + return args.repository.save(state.profile); + } } ``` ---- +## Selectors + +`context.watchProfileViewModel().value` rebuilds for the whole state. For +fine-grained rebuilds, use the generated selector widget. + +```dart +ProfileViewModelSelector( + selector: (state) => state.status, + builder: (context, status, child) { + return ProfileStatusBadge(status: status); + }, +) +``` + +Selector widgets are covered by rebuild-count tests in the shopping app: they +do not rebuild when unrelated state fields change. -## Key Concepts +## Effects -### Side Effects (`ViewModelListener`) -Use `ViewModelListener` to handle one-time events like navigation or showing snackbars. +Use the generated listener for one-shot effects. Listeners do not rebuild their +child when effects arrive. ```dart -CounterViewModelListener( +ProfileViewModelListener( listener: (context, effect) { - if (effect is ShowCelebration) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Yay!'))); + if (effect is ShowProfileSaved) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Saved')), + ); } }, - child: const CounterPage(), + child: const ProfilePage(), ) ``` -### Context Extensions -Dust generates extensions on `BuildContext` for easy access: +Emit effects from the ViewModel: -* `context.watchClassName()`: Rebuilds the widget when state changes. Returns a proxy to access `.value`. -* `context.readClassName()`: Returns the ViewModel instance. Does not trigger rebuilds. -* `context.watchClassName().select(...)`: Rebuilds only when the selected value changes. +```dart +void notifySaved() { + emitEffect(const ShowProfileSaved()); +} +``` ---- +## Async ViewModel -## Migration Guide +Async ViewModels use the same annotation with `mode: ViewModelMode.async`. +The annotated `state` type is the loaded data type; the generated base wraps +it in `AsyncState`. -**Coming from `Bloc` or `Provider`?** +```dart +@Derive([ToString(), Eq()]) +class HomePageData with _$HomePageData { + const HomePageData({ + required this.featuredProducts, + required this.categories, + }); -| Feature | `flutter_bloc` | `provider` | Dust | -| :--- | :--- | :--- | :--- | -| Core Logic | `Bloc` / `Cubit` | `ChangeNotifier` | `ViewModel` | -| Scoping | `BlocProvider` | `Provider` | `ViewModelScope` | -| UI Binding | `BlocBuilder` | `context.watch` | `context.watchViewModel().value` | -| State Access | `state` | `this` | `state` | -| Dependencies | `repository` | `this.repo` | `args.repo` | + final List featuredProducts; + final List categories; +} ---- +final class HomeViewModelArgs extends ViewModelArgs { + const HomeViewModelArgs({required this.repository, super.observer}); -## Generated Code Preview + final ShoppingRepository repository; +} -Dust generates documented public helpers in the `.g.dart` file. App code does -not edit this file; the comments are there to make generated APIs discoverable -in IDEs. +@ViewModel( + state: HomePageData, + args: HomeViewModelArgs, + mode: ViewModelMode.async, +) +class HomeViewModel extends $HomeViewModel { + HomeViewModel(super.args); -```dart -// counter_view_model.g.dart (simplified) -/// Generated base class for CounterViewModel. -/// -/// Extend this class in the user-authored ViewModel and forward typed args: -/// -/// ```dart -/// final class CounterViewModel extends $CounterViewModel { -/// CounterViewModel(super.args); -/// } -/// ``` -abstract class $CounterViewModel extends ViewModelBase { - $CounterViewModel(super.args) : super(initialState: const CounterState()); + @override + Future loadData() async { + final products = await args.repository.getProductsPage(limit: 6); + final categories = await args.repository.getCategories(); + return HomePageData( + featuredProducts: products, + categories: categories, + ); + } } +``` -/// Provides CounterViewModel to descendants and owns it by default. -/// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. -class CounterViewModelScope extends StatefulWidget { - /// Creates an owned CounterViewModel from typed args. - const CounterViewModelScope({ - super.key, - required this.args, - required this.create, - required this.child, - }); +Async ViewModels get: - /// Reads CounterViewModel without subscribing the caller to state changes. - static CounterViewModel read(BuildContext context); -} +- `load()`: clears visible data and loads fresh data. +- `refresh()`: loads fresh data while preserving previous visible data. +- `retry()`: aliases `refresh()`. +- `invalidateSelf()`: clears pending work and resets to `AsyncInitial`. +- `data`: current visible data, if present. +- `visibleData`: current or previous data, if present. -/// Generated BuildContext helpers for CounterViewModel. -extension CounterViewModelBuildContext on BuildContext { - _$CounterViewModelProxy watchCounterViewModel(); +Use the generated async builder for the common loading/data/error UI: - CounterViewModel readCounterViewModel(); -} +```dart +HomeViewModelBuilder( + loading: (_) => const CircularProgressIndicator(), + data: (context, data) => HomeContent(data: data), + error: (context, error, previousData) { + return HomeErrorView( + error: error, + previousData: previousData, + onRetry: context.readHomeViewModel().retry, + ); + }, +) +``` + +Use a raw switch only when the UI needs every lifecycle detail: + +```dart +return switch (context.watchHomeViewModel().value) { + AsyncData(data: final data) => HomeContent(data: data), + AsyncLoading( + hasPreviousData: true, + previousData: final previousData, + ) => + HomeContent(data: previousData as HomePageData, refreshing: true), + AsyncFailure( + error: final error, + previousData: final previousData, + ) => + HomeErrorView(error: error, previousData: previousData), + _ => const CircularProgressIndicator(), +}; ``` ---- +## Generated API + +Dust generates one support block per ViewModel: + +- `$CounterViewModel` / `$HomeViewModel` base class +- `CounterViewModelScope` / `HomeViewModelScope` +- `context.watchCounterViewModel().value` +- `context.readCounterViewModel()` +- `CounterViewModelSelector` +- `CounterViewModelListener` +- async-only `HomeViewModelBuilder` + +Generated proxies expose `.value` only. They do not mirror `state.*`, +`args.*`, or closure-backed `watch().select(...)`. + +## Configuration Reference + +| Property | Type | Description | +| :--- | :--- | :--- | +| `state` | `Type` | Required. Sync state type or async loaded data type. | +| `args` | `Type` | Optional `ViewModelArgs` subtype. Defaults to `ViewModelArgs`. | +| `initial` | Expression | Optional sync initial state. Not allowed with async mode. | +| `mode` | `ViewModelMode` | `ViewModelMode.sync` or `ViewModelMode.async`. Defaults to sync. | -## Best Practices +## Rules > [!WARNING] -> **Anti-Patterns to Avoid:** -> - **Don't** call async actions directly in `build()`. -> - **Don't** use `watch()` inside callbacks (use `read()` instead). -> - **Don't** mutate state lists or maps in place (always use `copyWith`). +> - Do not mutate state collections in place. Use immutable snapshots and `copyWith`. +> - Do not call async actions directly from `build()`. +> - Do not use `watch` inside callbacks; use `read`. +> - Put repositories, HTTP clients, sockets, and storage in `args`. +> - Put UI-changing data in state or loaded async data. diff --git a/examples/benchmark_project/lib/pages/benchmark_shell.dart b/examples/benchmark_project/lib/pages/benchmark_shell.dart index 802d5fe9..7d996ecc 100644 --- a/examples/benchmark_project/lib/pages/benchmark_shell.dart +++ b/examples/benchmark_project/lib/pages/benchmark_shell.dart @@ -9,7 +9,7 @@ class BenchmarkShell extends StatelessWidget { @override Widget build(BuildContext context) { - final activeFeature = context.watchBenchmarkViewModel().activeFeature; + final activeFeature = context.watchBenchmarkViewModel().value.activeFeature; return Scaffold( appBar: AppBar(title: Text('Dust benchmark: $activeFeature')), body: child, diff --git a/examples/benchmark_project/lib/state/benchmark_view_model.g.dart b/examples/benchmark_project/lib/state/benchmark_view_model.g.dart index 75c25989..05610cb3 100644 --- a/examples/benchmark_project/lib/state/benchmark_view_model.g.dart +++ b/examples/benchmark_project/lib/state/benchmark_view_model.g.dart @@ -13,34 +13,9 @@ part of 'benchmark_view_model.dart'; -final class _BenchmarkViewModelAspect { - const _BenchmarkViewModelAspect(this.selector); - - final R Function(BenchmarkState state) selector; - - bool hasChanged(BenchmarkState previous, BenchmarkState next) { - return selector(previous) != selector(next); - } -} - -BenchmarkMode _benchmarkViewModelSelectMode(BenchmarkState state) => state.mode; -final _benchmarkViewModelModeAspect = _BenchmarkViewModelAspect( - _benchmarkViewModelSelectMode, -); - -String _benchmarkViewModelSelectActiveFeature(BenchmarkState state) => state.activeFeature; -final _benchmarkViewModelActiveFeatureAspect = _BenchmarkViewModelAspect( - _benchmarkViewModelSelectActiveFeature, -); - -int _benchmarkViewModelSelectBuildsRun(BenchmarkState state) => state.buildsRun; -final _benchmarkViewModelBuildsRunAspect = _BenchmarkViewModelAspect( - _benchmarkViewModelSelectBuildsRun, -); - -/// Generated base class for BenchmarkViewModel. +/// Base class generated for BenchmarkViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class BenchmarkViewModel extends $BenchmarkViewModel { @@ -51,14 +26,12 @@ abstract class $BenchmarkViewModel extends ViewModelBase state.count); /// ``` class _$BenchmarkViewModelProxy { _$BenchmarkViewModelProxy(this._context); @@ -68,38 +41,98 @@ class _$BenchmarkViewModelProxy { BenchmarkState get value { return BenchmarkViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected BenchmarkState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class BenchmarkViewModelSelector extends StatefulWidget { + const BenchmarkViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(BenchmarkState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _BenchmarkViewModelSelectorState(); +} - BenchmarkMode get mode { - return BenchmarkViewModelScope.of( - _context, - aspect: _benchmarkViewModelModeAspect, - ).state.mode; +class _BenchmarkViewModelSelectorState extends State> { + BenchmarkViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = BenchmarkViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); } - String get activeFeature { - return BenchmarkViewModelScope.of( - _context, - aspect: _benchmarkViewModelActiveFeatureAspect, - ).state.activeFeature; + @override + void didUpdateWidget(BenchmarkViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); } - int get buildsRun { - return BenchmarkViewModelScope.of( - _context, - aspect: _benchmarkViewModelBuildsRunAspect, - ).state.buildsRun; + void _onViewModelStateChanged() { + _selectCurrent(); } - R select(R Function(BenchmarkState state) selector) { - final aspect = _BenchmarkViewModelAspect(selector); - return selector(BenchmarkViewModelScope.of(_context, aspect: aspect).value); + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides BenchmarkViewModel to descendants and owns it by default. +/// Creates and provides BenchmarkViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// BenchmarkViewModelScope( @@ -109,12 +142,13 @@ class _$BenchmarkViewModelProxy { /// ) /// ``` class BenchmarkViewModelScope extends StatefulWidget { - /// Creates an owned BenchmarkViewModel from typed args. + /// Creates and owns BenchmarkViewModel from typed args. const BenchmarkViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned BenchmarkViewModel without disposing it. @@ -123,27 +157,33 @@ class BenchmarkViewModelScope extends StatefulWidget { required BenchmarkViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final BenchmarkViewModelArgs Function(BuildContext context)? args; final BenchmarkViewModel Function(BuildContext context, BenchmarkViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final BenchmarkViewModel? value; final Widget child; - /// Reads BenchmarkViewModel without subscribing the caller to state changes. + /// Reads BenchmarkViewModel without rebuilding when state changes. static BenchmarkViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_BenchmarkViewModelInherited>() - ?.widget as _BenchmarkViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_BenchmarkViewModelInstance>() + ?.widget as _BenchmarkViewModelInstance?; if (scope == null) throw StateError('No BenchmarkViewModelScope found in context.'); return scope.viewModel; } - /// Watches BenchmarkViewModel and optionally subscribes to one generated aspect. - static BenchmarkViewModel of(BuildContext context, {_BenchmarkViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_BenchmarkViewModelInherited>( - aspect: aspect, - ); + static BenchmarkViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_BenchmarkViewModelInstance>(); + if (scope == null) throw StateError('No BenchmarkViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches BenchmarkViewModel and rebuilds when state changes. + static BenchmarkViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_BenchmarkViewModelInherited>(); if (scope == null) throw StateError('No BenchmarkViewModelScope found in context.'); return scope.viewModel; } @@ -154,13 +194,21 @@ class BenchmarkViewModelScope extends StatefulWidget { class _BenchmarkViewModelScopeState extends State { BenchmarkViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -171,14 +219,17 @@ class _BenchmarkViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - BenchmarkViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - BenchmarkViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -208,6 +259,7 @@ class _BenchmarkViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -216,16 +268,28 @@ class _BenchmarkViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(BenchmarkViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('BenchmarkViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -244,41 +308,41 @@ class _BenchmarkViewModelScopeState extends State { if (viewModel == null) { throw StateError('BenchmarkViewModelScope built before its view model was initialized.'); } - return _BenchmarkViewModelInherited( + return _BenchmarkViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _BenchmarkViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _BenchmarkViewModelInherited extends InheritedModel<_BenchmarkViewModelAspect> { - const _BenchmarkViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _BenchmarkViewModelInstance extends InheritedWidget { + const _BenchmarkViewModelInstance({required this.viewModel, required super.child}); final BenchmarkViewModel viewModel; - final BenchmarkState state; - /// Requires BenchmarkState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_BenchmarkViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_BenchmarkViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _BenchmarkViewModelInherited extends InheritedWidget { + const _BenchmarkViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final BenchmarkViewModel viewModel; + final BenchmarkState state; @override - bool updateShouldNotifyDependent( - _BenchmarkViewModelInherited oldWidget, - Set<_BenchmarkViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_BenchmarkViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from BenchmarkViewModel. +/// Handles one-shot effects from BenchmarkViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -305,7 +369,7 @@ class _BenchmarkViewModelListenerState extends State @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = BenchmarkViewModelScope.read(context); + final nextViewModel = BenchmarkViewModelScope._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; @@ -326,7 +390,7 @@ class _BenchmarkViewModelListenerState extends State Widget build(BuildContext context) => widget.child; } -/// Generated BuildContext helpers for BenchmarkViewModel. +/// BuildContext helpers generated for BenchmarkViewModel. /// /// ```dart /// final vm = context.readBenchmarkViewModel(); diff --git a/examples/shopping_app/lib/core/view_models/app_view_model.dart b/examples/shopping_app/lib/core/view_models/app_view_model.dart index 56d7f1f3..59c1a958 100644 --- a/examples/shopping_app/lib/core/view_models/app_view_model.dart +++ b/examples/shopping_app/lib/core/view_models/app_view_model.dart @@ -3,6 +3,7 @@ import 'package:dust_dart/serde.dart'; import '../data/shopping_repository.dart'; import '../services/storage_service.dart'; +import '../../features/products/models/product.dart'; part 'app_view_model.g.dart'; @@ -30,3 +31,69 @@ final class AppViewModelArgs extends ViewModelArgs { class AppViewModel extends $AppViewModel { AppViewModel(super.args); } + +enum BnbTab { home, products, cart, orders, profile } + +@Derive([ToString(), Eq(), CopyWith()]) +class BnbState with _$BnbState { + const BnbState({this.currentTab = BnbTab.home}); + + final BnbTab currentTab; + + int get currentIndex => currentTab.index; +} + +final class BnbViewModelArgs extends ViewModelArgs { + const BnbViewModelArgs({super.observer}); +} + +@ViewModel(state: BnbState, args: BnbViewModelArgs) +class BnbViewModel extends $BnbViewModel { + BnbViewModel(super.args); + + void select(BnbTab tab) { + if (tab == state.currentTab) return; + emit(state.copyWith(currentTab: tab)); + } + + void selectIndex(int index) { + RangeError.checkValidIndex(index, BnbTab.values); + select(BnbTab.values[index]); + } +} + +@Derive([ToString(), Eq()]) +class HomePageData with _$HomePageData { + const HomePageData({ + required this.featuredProducts, + required this.categories, + }); + + final List featuredProducts; + final List categories; +} + +final class HomeViewModelArgs extends ViewModelArgs { + const HomeViewModelArgs({required this.repository, super.observer}); + + final ShoppingRepository repository; +} + +@ViewModel( + state: HomePageData, + args: HomeViewModelArgs, + mode: ViewModelMode.async, +) +class HomeViewModel extends $HomeViewModel { + HomeViewModel(super.args); + + @override + Future loadData() async { + final products = await args.repository.getProductsPage(limit: 6); + final categories = await args.repository.getCategories(); + return HomePageData( + featuredProducts: products, + categories: categories, + ); + } +} diff --git a/examples/shopping_app/lib/core/view_models/app_view_model.g.dart b/examples/shopping_app/lib/core/view_models/app_view_model.g.dart index ea0c9199..5a8ecb66 100644 --- a/examples/shopping_app/lib/core/view_models/app_view_model.g.dart +++ b/examples/shopping_app/lib/core/view_models/app_view_model.g.dart @@ -13,6 +13,9 @@ part of 'app_view_model.dart'; +const DeepCollectionEquality _homePageDataFeaturedProductsEquality = DeepCollectionEquality(); +const DeepCollectionEquality _homePageDataCategoriesEquality = DeepCollectionEquality(); + mixin _$AppState { @override String toString() { @@ -50,6 +53,74 @@ mixin _$AppState { _$AppStateCopyWith get copyWith => _$AppStateCopyWithImpl(this as AppState, (value) => value); } +mixin _$BnbState { + @override + String toString() { + final self = this as BnbState; + return 'BnbState(' + 'currentTab: ${self.currentTab}' + ')'; + } + + @override + bool operator ==(Object other) { + final self = this as BnbState; + return identical(this, other) || + other is BnbState && + runtimeType == other.runtimeType && + other.currentTab == self.currentTab; + } + + @override + int get hashCode { + final self = this as BnbState; + return Object.hashAll([ + runtimeType, + self.currentTab, + ]); + } + + /// Creates a copy of this `BnbState` with selected fields replaced. + /// + /// Usage: + /// ```dart + /// final updated = bnbState.copyWith(); + /// ``` + @pragma('vm:prefer-inline') + _$BnbStateCopyWith get copyWith => _$BnbStateCopyWithImpl(this as BnbState, (value) => value); +} + +mixin _$HomePageData { + @override + String toString() { + final self = this as HomePageData; + return 'HomePageData(' + 'featuredProducts: ${self.featuredProducts}, ' + 'categories: ${self.categories}' + ')'; + } + + @override + bool operator ==(Object other) { + final self = this as HomePageData; + return identical(this, other) || + other is HomePageData && + runtimeType == other.runtimeType && + _homePageDataFeaturedProductsEquality.equals(other.featuredProducts, self.featuredProducts) && + _homePageDataCategoriesEquality.equals(other.categories, self.categories); + } + + @override + int get hashCode { + final self = this as HomePageData; + return Object.hashAll([ + runtimeType, + _homePageDataFeaturedProductsEquality.hash(self.featuredProducts), + _homePageDataCategoriesEquality.hash(self.categories), + ]); + } +} + // CopyWith API inspired by Freezed. /// @nodoc @@ -78,24 +149,35 @@ final class _$AppStateCopyWithImpl<$Res> implements _$AppStateCopyWith<$Res> { ); } } -final class _AppViewModelAspect { - const _AppViewModelAspect(this.selector); +/// @nodoc +abstract class _$BnbStateCopyWith<$Res> { + $Res call({ + BnbTab? currentTab, + }); +} - final R Function(AppState state) selector; +/// @nodoc +final class _$BnbStateCopyWithImpl<$Res> implements _$BnbStateCopyWith<$Res> { + const _$BnbStateCopyWithImpl(this._self, this._then); + + final BnbState _self; + final $Res Function(BnbState) _then; - bool hasChanged(AppState previous, AppState next) { - return selector(previous) != selector(next); + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? currentTab = null, + }) { + return _then( + BnbState( + currentTab: currentTab == null ? _self.currentTab : currentTab as BnbTab, + ) + ); } } - -AppBackendMode _appViewModelSelectBackendMode(AppState state) => state.backendMode; -final _appViewModelBackendModeAspect = _AppViewModelAspect( - _appViewModelSelectBackendMode, -); - -/// Generated base class for AppViewModel. +/// Base class generated for AppViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class AppViewModel extends $AppViewModel { @@ -106,14 +188,12 @@ abstract class $AppViewModel extends ViewModelBase { $AppViewModel(super.args) : super(initialState: const AppState()); } -/// Typed state reader returned by `context.watchAppViewModel()`. +/// Reads AppViewModel state from `BuildContext`. /// -/// Read `value` to rebuild for the whole state, or call `select` to rebuild only -/// when the selected value changes. +/// Read `value` when the widget should rebuild for any state change. /// /// ```dart /// final state = context.watchAppViewModel().value; -/// final count = context.watchAppViewModel().select((state) => state.count); /// ``` class _$AppViewModelProxy { _$AppViewModelProxy(this._context); @@ -123,24 +203,98 @@ class _$AppViewModelProxy { AppState get value { return AppViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected AppState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class AppViewModelSelector extends StatefulWidget { + const AppViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(AppState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _AppViewModelSelectorState(); +} + +class _AppViewModelSelectorState extends State> { + AppViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = AppViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } - AppBackendMode get backendMode { - return AppViewModelScope.of( - _context, - aspect: _appViewModelBackendModeAspect, - ).state.backendMode; + @override + void didUpdateWidget(AppViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } + + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); } - R select(R Function(AppState state) selector) { - final aspect = _AppViewModelAspect(selector); - return selector(AppViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides AppViewModel to descendants and owns it by default. +/// Creates and provides AppViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// AppViewModelScope( @@ -150,12 +304,13 @@ class _$AppViewModelProxy { /// ) /// ``` class AppViewModelScope extends StatefulWidget { - /// Creates an owned AppViewModel from typed args. + /// Creates and owns AppViewModel from typed args. const AppViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned AppViewModel without disposing it. @@ -164,27 +319,33 @@ class AppViewModelScope extends StatefulWidget { required AppViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final AppViewModelArgs Function(BuildContext context)? args; final AppViewModel Function(BuildContext context, AppViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final AppViewModel? value; final Widget child; - /// Reads AppViewModel without subscribing the caller to state changes. + /// Reads AppViewModel without rebuilding when state changes. static AppViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_AppViewModelInherited>() - ?.widget as _AppViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_AppViewModelInstance>() + ?.widget as _AppViewModelInstance?; if (scope == null) throw StateError('No AppViewModelScope found in context.'); return scope.viewModel; } - /// Watches AppViewModel and optionally subscribes to one generated aspect. - static AppViewModel of(BuildContext context, {_AppViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_AppViewModelInherited>( - aspect: aspect, - ); + static AppViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_AppViewModelInstance>(); + if (scope == null) throw StateError('No AppViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches AppViewModel and rebuilds when state changes. + static AppViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_AppViewModelInherited>(); if (scope == null) throw StateError('No AppViewModelScope found in context.'); return scope.viewModel; } @@ -195,13 +356,21 @@ class AppViewModelScope extends StatefulWidget { class _AppViewModelScopeState extends State { AppViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -212,14 +381,17 @@ class _AppViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - AppViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - AppViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -249,6 +421,7 @@ class _AppViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -257,16 +430,28 @@ class _AppViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(AppViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('AppViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -285,41 +470,41 @@ class _AppViewModelScopeState extends State { if (viewModel == null) { throw StateError('AppViewModelScope built before its view model was initialized.'); } - return _AppViewModelInherited( + return _AppViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _AppViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _AppViewModelInherited extends InheritedModel<_AppViewModelAspect> { - const _AppViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _AppViewModelInstance extends InheritedWidget { + const _AppViewModelInstance({required this.viewModel, required super.child}); final AppViewModel viewModel; - final AppState state; - /// Requires AppState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_AppViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_AppViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _AppViewModelInherited extends InheritedWidget { + const _AppViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final AppViewModel viewModel; + final AppState state; @override - bool updateShouldNotifyDependent( - _AppViewModelInherited oldWidget, - Set<_AppViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_AppViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from AppViewModel. +/// Handles one-shot effects from AppViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -346,7 +531,7 @@ class _AppViewModelListenerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = AppViewModelScope.read(context); + final nextViewModel = AppViewModelScope._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; @@ -367,7 +552,7 @@ class _AppViewModelListenerState extends State { Widget build(BuildContext context) => widget.child; } -/// Generated BuildContext helpers for AppViewModel. +/// BuildContext helpers generated for AppViewModel. /// /// ```dart /// final vm = context.readAppViewModel(); @@ -380,3 +565,824 @@ extension AppViewModelBuildContext on BuildContext { AppViewModel readAppViewModel() => AppViewModelScope.read(this); } +/// Base class generated for BnbViewModel. +/// +/// Extend it from your ViewModel and forward typed args: +/// +/// ```dart +/// final class BnbViewModel extends $BnbViewModel { +/// BnbViewModel(super.args); +/// } +/// ``` +abstract class $BnbViewModel extends ViewModelBase { + $BnbViewModel(super.args) : super(initialState: const BnbState()); +} + +/// Reads BnbViewModel state from `BuildContext`. +/// +/// Read `value` when the widget should rebuild for any state change. +/// +/// ```dart +/// final state = context.watchBnbViewModel().value; +/// ``` +class _$BnbViewModelProxy { + _$BnbViewModelProxy(this._context); + + final BuildContext _context; + + BnbState get value { + return BnbViewModelScope.of(_context).value; + } +} + +/// Rebuilds when a selected BnbState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class BnbViewModelSelector extends StatefulWidget { + const BnbViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(BnbState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _BnbViewModelSelectorState(); +} + +class _BnbViewModelSelectorState extends State> { + BnbViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = BnbViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget(BnbViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } + + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); + } +} + +/// Creates and provides BnbViewModel to descendants. +/// +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. +/// +/// ```dart +/// BnbViewModelScope( +/// args: (context) => BnbViewModelArgs(...), +/// create: (context, args) => BnbViewModel(args), +/// child: const FeaturePage(), +/// ) +/// ``` +class BnbViewModelScope extends StatefulWidget { + /// Creates and owns BnbViewModel from typed args. + const BnbViewModelScope({ + super.key, + required this.args, + required this.create, + required this.child, + this.identity, + }) : value = null; + + /// Provides an externally owned BnbViewModel without disposing it. + const BnbViewModelScope.value({ + super.key, + required BnbViewModel this.value, + required this.child, + }) : args = null, + create = null, + identity = null; + + final BnbViewModelArgs Function(BuildContext context)? args; + final BnbViewModel Function(BuildContext context, BnbViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; + final BnbViewModel? value; + final Widget child; + + /// Reads BnbViewModel without rebuilding when state changes. + static BnbViewModel read(BuildContext context) { + final scope = context + .getElementForInheritedWidgetOfExactType<_BnbViewModelInstance>() + ?.widget as _BnbViewModelInstance?; + if (scope == null) throw StateError('No BnbViewModelScope found in context.'); + return scope.viewModel; + } + + static BnbViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_BnbViewModelInstance>(); + if (scope == null) throw StateError('No BnbViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches BnbViewModel and rebuilds when state changes. + static BnbViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_BnbViewModelInherited>(); + if (scope == null) throw StateError('No BnbViewModelScope found in context.'); + return scope.viewModel; + } + + @override + State createState() => _BnbViewModelScopeState(); +} + +class _BnbViewModelScopeState extends State { + BnbViewModel? _viewModel; + Object? _identity; + bool _ownsViewModel = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); + } + } + + @override + void didUpdateWidget(BnbViewModelScope oldWidget) { + super.didUpdateWidget(oldWidget); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false); + } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } + } + } + + BnbViewModel _createOwnedViewModel() { + final argsFactory = widget.args; + final create = widget.create; + if (argsFactory == null || create == null) { + throw StateError('Owned BnbViewModelScope requires args and create.'); + } + late final BnbViewModel created; + try { + created = create(context, argsFactory(context)); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + StateError( + 'BnbViewModelScope failed to create its view model. Check the generated ' + 'scope args/create dependency injection. Original error: $error', + ), + stackTrace, + ); + } + return created; + } + + void _replaceViewModel( + BnbViewModel nextViewModel, { + required bool ownsViewModel, + bool notify = true, + }) { + final previous = _viewModel; + if (identical(previous, nextViewModel)) { + _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); + if (notify && mounted) setState(() {}); + return; + } + previous?.removeListener(_onViewModelStateChanged); + if (_ownsViewModel) previous?.dispose(); + _viewModel = nextViewModel; + _ownsViewModel = ownsViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _scheduleInit(nextViewModel); + if (notify && mounted) setState(() {}); + } + + void _scheduleInit(BnbViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('BnbViewModelScope init failed'), + ), + ); + } + }); + } + + void _onViewModelStateChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + final viewModel = _viewModel; + viewModel?.removeListener(_onViewModelStateChanged); + if (_ownsViewModel) viewModel?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final viewModel = _viewModel; + if (viewModel == null) { + throw StateError('BnbViewModelScope built before its view model was initialized.'); + } + return _BnbViewModelInstance( + viewModel: viewModel, + child: _BnbViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), + ); + } +} + +class _BnbViewModelInstance extends InheritedWidget { + const _BnbViewModelInstance({required this.viewModel, required super.child}); + + final BnbViewModel viewModel; + + @override + bool updateShouldNotify(_BnbViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _BnbViewModelInherited extends InheritedWidget { + const _BnbViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final BnbViewModel viewModel; + final BnbState state; + + @override + bool updateShouldNotify(_BnbViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; + } +} + +/// Handles one-shot effects from BnbViewModel. +/// +/// Effects are delivered without changing state and do not rebuild `child`. +/// +/// ```dart +/// BnbViewModelListener( +/// listener: onEffect, +/// child: const FeaturePage(), +/// ) +/// ``` +class BnbViewModelListener extends StatefulWidget { + const BnbViewModelListener({super.key, required this.listener, required this.child}); + + final void Function(BuildContext context, Object effect) listener; + final Widget child; + + @override + State createState() => _BnbViewModelListenerState(); +} + +class _BnbViewModelListenerState extends State { + StreamSubscription? _sub; + BnbViewModel? _viewModel; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = BnbViewModelScope._watchInstance(context); + if (_viewModel == nextViewModel) return; + _sub?.cancel(); + _viewModel = nextViewModel; + _sub = nextViewModel.effects.listen(_onEffect); + } + + void _onEffect(Object effect) { + if (mounted) widget.listener(context, effect); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +/// BuildContext helpers generated for BnbViewModel. +/// +/// ```dart +/// final vm = context.readBnbViewModel(); +/// final state = context.watchBnbViewModel().value; +/// ``` +extension BnbViewModelBuildContext on BuildContext { + _$BnbViewModelProxy watchBnbViewModel() { + return _$BnbViewModelProxy(this); + } + + BnbViewModel readBnbViewModel() => BnbViewModelScope.read(this); +} +/// Async base class generated for HomeViewModel. +/// +/// Extend it from your ViewModel and implement `loadData`. +/// +/// ```dart +/// final class HomeViewModel extends $HomeViewModel { +/// HomeViewModel(super.args); +/// +/// @override +/// Future loadData() { +/// return args.repository.load(); +/// } +/// } +/// ``` +abstract class $HomeViewModel extends AsyncViewModelBase { + $HomeViewModel(super.args); +} + +/// Reads HomeViewModel state from `BuildContext`. +/// +/// Read `value` when the widget should rebuild for any state change. +/// +/// ```dart +/// final state = context.watchHomeViewModel().value; +/// ``` +class _$HomeViewModelProxy { + _$HomeViewModelProxy(this._context); + + final BuildContext _context; + + AsyncState get value { + return HomeViewModelScope.of(_context).value; + } +} + +/// Rebuilds when a selected AsyncState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class HomeViewModelSelector extends StatefulWidget { + const HomeViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(AsyncState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _HomeViewModelSelectorState(); +} + +class _HomeViewModelSelectorState extends State> { + HomeViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = HomeViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget(HomeViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } + + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); + } +} + +/// Builds the common loading, data, and error UI for HomeViewModel. +class HomeViewModelBuilder extends StatelessWidget { + const HomeViewModelBuilder({ + super.key, + required this.loading, + required this.data, + required this.error, + }); + + final Widget Function(BuildContext context) loading; + final Widget Function(BuildContext context, HomePageData data) data; + final Widget Function( + BuildContext context, + Object error, + HomePageData? previousData, + ) error; + + @override + Widget build(BuildContext context) { + final state = context.watchHomeViewModel().value; + return switch (state) { + AsyncData(data: final loadedData) => + data(context, loadedData), + AsyncLoading( + hasPreviousData: true, + previousData: final previousData, + ) => data(context, previousData as HomePageData), + AsyncFailure( + error: final loadError, + previousData: final previousData, + ) => error(context, loadError, previousData), + _ => loading(context), + }; + } +} + +/// Creates and provides HomeViewModel to descendants. +/// +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. +/// +/// ```dart +/// HomeViewModelScope( +/// args: (context) => HomeViewModelArgs(...), +/// create: (context, args) => HomeViewModel(args), +/// child: const FeaturePage(), +/// ) +/// ``` +class HomeViewModelScope extends StatefulWidget { + /// Creates and owns HomeViewModel from typed args. + const HomeViewModelScope({ + super.key, + required this.args, + required this.create, + required this.child, + this.identity, + }) : value = null; + + /// Provides an externally owned HomeViewModel without disposing it. + const HomeViewModelScope.value({ + super.key, + required HomeViewModel this.value, + required this.child, + }) : args = null, + create = null, + identity = null; + + final HomeViewModelArgs Function(BuildContext context)? args; + final HomeViewModel Function(BuildContext context, HomeViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; + final HomeViewModel? value; + final Widget child; + + /// Reads HomeViewModel without rebuilding when state changes. + static HomeViewModel read(BuildContext context) { + final scope = context + .getElementForInheritedWidgetOfExactType<_HomeViewModelInstance>() + ?.widget as _HomeViewModelInstance?; + if (scope == null) throw StateError('No HomeViewModelScope found in context.'); + return scope.viewModel; + } + + static HomeViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_HomeViewModelInstance>(); + if (scope == null) throw StateError('No HomeViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches HomeViewModel and rebuilds when state changes. + static HomeViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_HomeViewModelInherited>(); + if (scope == null) throw StateError('No HomeViewModelScope found in context.'); + return scope.viewModel; + } + + @override + State createState() => _HomeViewModelScopeState(); +} + +class _HomeViewModelScopeState extends State { + HomeViewModel? _viewModel; + Object? _identity; + bool _ownsViewModel = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); + } + } + + @override + void didUpdateWidget(HomeViewModelScope oldWidget) { + super.didUpdateWidget(oldWidget); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false); + } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } + } + } + + HomeViewModel _createOwnedViewModel() { + final argsFactory = widget.args; + final create = widget.create; + if (argsFactory == null || create == null) { + throw StateError('Owned HomeViewModelScope requires args and create.'); + } + late final HomeViewModel created; + try { + created = create(context, argsFactory(context)); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + StateError( + 'HomeViewModelScope failed to create its view model. Check the generated ' + 'scope args/create dependency injection. Original error: $error', + ), + stackTrace, + ); + } + return created; + } + + void _replaceViewModel( + HomeViewModel nextViewModel, { + required bool ownsViewModel, + bool notify = true, + }) { + final previous = _viewModel; + if (identical(previous, nextViewModel)) { + _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); + if (notify && mounted) setState(() {}); + return; + } + previous?.removeListener(_onViewModelStateChanged); + if (_ownsViewModel) previous?.dispose(); + _viewModel = nextViewModel; + _ownsViewModel = ownsViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _scheduleInit(nextViewModel); + if (notify && mounted) setState(() {}); + } + + void _scheduleInit(HomeViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('HomeViewModelScope init failed'), + ), + ); + } + }); + } + + void _onViewModelStateChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + final viewModel = _viewModel; + viewModel?.removeListener(_onViewModelStateChanged); + if (_ownsViewModel) viewModel?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final viewModel = _viewModel; + if (viewModel == null) { + throw StateError('HomeViewModelScope built before its view model was initialized.'); + } + return _HomeViewModelInstance( + viewModel: viewModel, + child: _HomeViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), + ); + } +} + +class _HomeViewModelInstance extends InheritedWidget { + const _HomeViewModelInstance({required this.viewModel, required super.child}); + + final HomeViewModel viewModel; + + @override + bool updateShouldNotify(_HomeViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _HomeViewModelInherited extends InheritedWidget { + const _HomeViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final HomeViewModel viewModel; + final AsyncState state; + + @override + bool updateShouldNotify(_HomeViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; + } +} + +/// Handles one-shot effects from HomeViewModel. +/// +/// Effects are delivered without changing state and do not rebuild `child`. +/// +/// ```dart +/// HomeViewModelListener( +/// listener: onEffect, +/// child: const FeaturePage(), +/// ) +/// ``` +class HomeViewModelListener extends StatefulWidget { + const HomeViewModelListener({super.key, required this.listener, required this.child}); + + final void Function(BuildContext context, Object effect) listener; + final Widget child; + + @override + State createState() => _HomeViewModelListenerState(); +} + +class _HomeViewModelListenerState extends State { + StreamSubscription? _sub; + HomeViewModel? _viewModel; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = HomeViewModelScope._watchInstance(context); + if (_viewModel == nextViewModel) return; + _sub?.cancel(); + _viewModel = nextViewModel; + _sub = nextViewModel.effects.listen(_onEffect); + } + + void _onEffect(Object effect) { + if (mounted) widget.listener(context, effect); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +/// BuildContext helpers generated for HomeViewModel. +/// +/// ```dart +/// final vm = context.readHomeViewModel(); +/// final state = context.watchHomeViewModel().value; +/// ``` +extension HomeViewModelBuildContext on BuildContext { + _$HomeViewModelProxy watchHomeViewModel() { + return _$HomeViewModelProxy(this); + } + + HomeViewModel readHomeViewModel() => HomeViewModelScope.read(this); +} diff --git a/examples/shopping_app/lib/features/auth/view_models/auth_view_model.g.dart b/examples/shopping_app/lib/features/auth/view_models/auth_view_model.g.dart index 75eca0ba..b77481e7 100644 --- a/examples/shopping_app/lib/features/auth/view_models/auth_view_model.g.dart +++ b/examples/shopping_app/lib/features/auth/view_models/auth_view_model.g.dart @@ -13,39 +13,9 @@ part of 'auth_view_model.dart'; -final class _AuthViewModelAspect { - const _AuthViewModelAspect(this.selector); - - final R Function(AuthState state) selector; - - bool hasChanged(AuthState previous, AuthState next) { - return selector(previous) != selector(next); - } -} - -Object? _authViewModelSelectUser(AuthState state) => state.user; -final _authViewModelUserAspect = _AuthViewModelAspect( - _authViewModelSelectUser, -); - -String? _authViewModelSelectToken(AuthState state) => state.token; -final _authViewModelTokenAspect = _AuthViewModelAspect( - _authViewModelSelectToken, -); - -AuthStatus _authViewModelSelectStatus(AuthState state) => state.status; -final _authViewModelStatusAspect = _AuthViewModelAspect( - _authViewModelSelectStatus, -); - -String? _authViewModelSelectErrorMessage(AuthState state) => state.errorMessage; -final _authViewModelErrorMessageAspect = _AuthViewModelAspect( - _authViewModelSelectErrorMessage, -); - -/// Generated base class for AuthViewModel. +/// Base class generated for AuthViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class AuthViewModel extends $AuthViewModel { @@ -56,14 +26,12 @@ abstract class $AuthViewModel extends ViewModelBase state.count); /// ``` class _$AuthViewModelProxy { _$AuthViewModelProxy(this._context); @@ -73,45 +41,98 @@ class _$AuthViewModelProxy { AuthState get value { return AuthViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected AuthState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class AuthViewModelSelector extends StatefulWidget { + const AuthViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); - Object? get user { - return AuthViewModelScope.of( - _context, - aspect: _authViewModelUserAspect, - ).state.user; + final R Function(AuthState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _AuthViewModelSelectorState(); +} + +class _AuthViewModelSelectorState extends State> { + AuthViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = AuthViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); } - String? get token { - return AuthViewModelScope.of( - _context, - aspect: _authViewModelTokenAspect, - ).state.token; + @override + void didUpdateWidget(AuthViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } + + void _onViewModelStateChanged() { + _selectCurrent(); } - AuthStatus get status { - return AuthViewModelScope.of( - _context, - aspect: _authViewModelStatusAspect, - ).state.status; + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } } - String? get errorMessage { - return AuthViewModelScope.of( - _context, - aspect: _authViewModelErrorMessageAspect, - ).state.errorMessage; + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); } - R select(R Function(AuthState state) selector) { - final aspect = _AuthViewModelAspect(selector); - return selector(AuthViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides AuthViewModel to descendants and owns it by default. +/// Creates and provides AuthViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// AuthViewModelScope( @@ -121,12 +142,13 @@ class _$AuthViewModelProxy { /// ) /// ``` class AuthViewModelScope extends StatefulWidget { - /// Creates an owned AuthViewModel from typed args. + /// Creates and owns AuthViewModel from typed args. const AuthViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned AuthViewModel without disposing it. @@ -135,27 +157,33 @@ class AuthViewModelScope extends StatefulWidget { required AuthViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final AuthViewModelArgs Function(BuildContext context)? args; final AuthViewModel Function(BuildContext context, AuthViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final AuthViewModel? value; final Widget child; - /// Reads AuthViewModel without subscribing the caller to state changes. + /// Reads AuthViewModel without rebuilding when state changes. static AuthViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_AuthViewModelInherited>() - ?.widget as _AuthViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_AuthViewModelInstance>() + ?.widget as _AuthViewModelInstance?; if (scope == null) throw StateError('No AuthViewModelScope found in context.'); return scope.viewModel; } - /// Watches AuthViewModel and optionally subscribes to one generated aspect. - static AuthViewModel of(BuildContext context, {_AuthViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_AuthViewModelInherited>( - aspect: aspect, - ); + static AuthViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_AuthViewModelInstance>(); + if (scope == null) throw StateError('No AuthViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches AuthViewModel and rebuilds when state changes. + static AuthViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_AuthViewModelInherited>(); if (scope == null) throw StateError('No AuthViewModelScope found in context.'); return scope.viewModel; } @@ -166,13 +194,21 @@ class AuthViewModelScope extends StatefulWidget { class _AuthViewModelScopeState extends State { AuthViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -183,14 +219,17 @@ class _AuthViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - AuthViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - AuthViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -220,6 +259,7 @@ class _AuthViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -228,16 +268,28 @@ class _AuthViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(AuthViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('AuthViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -256,41 +308,41 @@ class _AuthViewModelScopeState extends State { if (viewModel == null) { throw StateError('AuthViewModelScope built before its view model was initialized.'); } - return _AuthViewModelInherited( + return _AuthViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _AuthViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _AuthViewModelInherited extends InheritedModel<_AuthViewModelAspect> { - const _AuthViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _AuthViewModelInstance extends InheritedWidget { + const _AuthViewModelInstance({required this.viewModel, required super.child}); final AuthViewModel viewModel; - final AuthState state; - /// Requires AuthState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_AuthViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_AuthViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _AuthViewModelInherited extends InheritedWidget { + const _AuthViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final AuthViewModel viewModel; + final AuthState state; @override - bool updateShouldNotifyDependent( - _AuthViewModelInherited oldWidget, - Set<_AuthViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_AuthViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from AuthViewModel. +/// Handles one-shot effects from AuthViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -317,7 +369,7 @@ class _AuthViewModelListenerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = AuthViewModelScope.read(context); + final nextViewModel = AuthViewModelScope._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; @@ -338,7 +390,7 @@ class _AuthViewModelListenerState extends State { Widget build(BuildContext context) => widget.child; } -/// Generated BuildContext helpers for AuthViewModel. +/// BuildContext helpers generated for AuthViewModel. /// /// ```dart /// final vm = context.readAuthViewModel(); diff --git a/examples/shopping_app/lib/features/cart/view_models/cart_view_model.dart b/examples/shopping_app/lib/features/cart/view_models/cart_view_model.dart index 3fef1b96..7af96341 100644 --- a/examples/shopping_app/lib/features/cart/view_models/cart_view_model.dart +++ b/examples/shopping_app/lib/features/cart/view_models/cart_view_model.dart @@ -94,7 +94,7 @@ class CartViewModel extends $CartViewModel { } void clearCart() { - emit(const CartState()); + invalidateSelf(); emitEffect( const CartNotification( message: 'Cart cleared', diff --git a/examples/shopping_app/lib/features/cart/view_models/cart_view_model.g.dart b/examples/shopping_app/lib/features/cart/view_models/cart_view_model.g.dart index d7447301..10542e43 100644 --- a/examples/shopping_app/lib/features/cart/view_models/cart_view_model.g.dart +++ b/examples/shopping_app/lib/features/cart/view_models/cart_view_model.g.dart @@ -13,29 +13,9 @@ part of 'cart_view_model.dart'; -final class _CartViewModelAspect { - const _CartViewModelAspect(this.selector); - - final R Function(CartState state) selector; - - bool hasChanged(CartState previous, CartState next) { - return selector(previous) != selector(next); - } -} - -List _cartViewModelSelectItems(CartState state) => state.items; -final _cartViewModelItemsAspect = _CartViewModelAspect>( - _cartViewModelSelectItems, -); - -CartNotification? _cartViewModelSelectNotification(CartState state) => state.notification; -final _cartViewModelNotificationAspect = _CartViewModelAspect( - _cartViewModelSelectNotification, -); - -/// Generated base class for CartViewModel. +/// Base class generated for CartViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class CartViewModel extends $CartViewModel { @@ -46,14 +26,12 @@ abstract class $CartViewModel extends ViewModelBase state.count); /// ``` class _$CartViewModelProxy { _$CartViewModelProxy(this._context); @@ -63,31 +41,98 @@ class _$CartViewModelProxy { CartState get value { return CartViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected CartState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class CartViewModelSelector extends StatefulWidget { + const CartViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(CartState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _CartViewModelSelectorState(); +} + +class _CartViewModelSelectorState extends State> { + CartViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = CartViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget(CartViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } - List get items { - return CartViewModelScope.of( - _context, - aspect: _cartViewModelItemsAspect, - ).state.items; + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } } - CartNotification? get notification { - return CartViewModelScope.of( - _context, - aspect: _cartViewModelNotificationAspect, - ).state.notification; + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); } - R select(R Function(CartState state) selector) { - final aspect = _CartViewModelAspect(selector); - return selector(CartViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides CartViewModel to descendants and owns it by default. +/// Creates and provides CartViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// CartViewModelScope( @@ -97,12 +142,13 @@ class _$CartViewModelProxy { /// ) /// ``` class CartViewModelScope extends StatefulWidget { - /// Creates an owned CartViewModel from typed args. + /// Creates and owns CartViewModel from typed args. const CartViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned CartViewModel without disposing it. @@ -111,27 +157,33 @@ class CartViewModelScope extends StatefulWidget { required CartViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final CartViewModelArgs Function(BuildContext context)? args; final CartViewModel Function(BuildContext context, CartViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final CartViewModel? value; final Widget child; - /// Reads CartViewModel without subscribing the caller to state changes. + /// Reads CartViewModel without rebuilding when state changes. static CartViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_CartViewModelInherited>() - ?.widget as _CartViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_CartViewModelInstance>() + ?.widget as _CartViewModelInstance?; if (scope == null) throw StateError('No CartViewModelScope found in context.'); return scope.viewModel; } - /// Watches CartViewModel and optionally subscribes to one generated aspect. - static CartViewModel of(BuildContext context, {_CartViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_CartViewModelInherited>( - aspect: aspect, - ); + static CartViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_CartViewModelInstance>(); + if (scope == null) throw StateError('No CartViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches CartViewModel and rebuilds when state changes. + static CartViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_CartViewModelInherited>(); if (scope == null) throw StateError('No CartViewModelScope found in context.'); return scope.viewModel; } @@ -142,13 +194,21 @@ class CartViewModelScope extends StatefulWidget { class _CartViewModelScopeState extends State { CartViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -159,14 +219,17 @@ class _CartViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - CartViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - CartViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -196,6 +259,7 @@ class _CartViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -204,16 +268,28 @@ class _CartViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(CartViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('CartViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -232,41 +308,41 @@ class _CartViewModelScopeState extends State { if (viewModel == null) { throw StateError('CartViewModelScope built before its view model was initialized.'); } - return _CartViewModelInherited( + return _CartViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _CartViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _CartViewModelInherited extends InheritedModel<_CartViewModelAspect> { - const _CartViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _CartViewModelInstance extends InheritedWidget { + const _CartViewModelInstance({required this.viewModel, required super.child}); final CartViewModel viewModel; - final CartState state; - /// Requires CartState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_CartViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_CartViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _CartViewModelInherited extends InheritedWidget { + const _CartViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final CartViewModel viewModel; + final CartState state; @override - bool updateShouldNotifyDependent( - _CartViewModelInherited oldWidget, - Set<_CartViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_CartViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from CartViewModel. +/// Handles one-shot effects from CartViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -293,7 +369,7 @@ class _CartViewModelListenerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = CartViewModelScope.read(context); + final nextViewModel = CartViewModelScope._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; @@ -314,7 +390,7 @@ class _CartViewModelListenerState extends State { Widget build(BuildContext context) => widget.child; } -/// Generated BuildContext helpers for CartViewModel. +/// BuildContext helpers generated for CartViewModel. /// /// ```dart /// final vm = context.readCartViewModel(); diff --git a/examples/shopping_app/lib/features/checkout/view_models/checkout_view_model.dart b/examples/shopping_app/lib/features/checkout/view_models/checkout_view_model.dart index 8077c635..18986141 100644 --- a/examples/shopping_app/lib/features/checkout/view_models/checkout_view_model.dart +++ b/examples/shopping_app/lib/features/checkout/view_models/checkout_view_model.dart @@ -60,6 +60,6 @@ class CheckoutViewModel extends $CheckoutViewModel { } void reset() { - emit(const CheckoutState()); + invalidateSelf(); } } diff --git a/examples/shopping_app/lib/features/checkout/view_models/checkout_view_model.g.dart b/examples/shopping_app/lib/features/checkout/view_models/checkout_view_model.g.dart index ee1c399e..89cc3e72 100644 --- a/examples/shopping_app/lib/features/checkout/view_models/checkout_view_model.g.dart +++ b/examples/shopping_app/lib/features/checkout/view_models/checkout_view_model.g.dart @@ -13,54 +13,9 @@ part of 'checkout_view_model.dart'; -final class _CheckoutViewModelAspect { - const _CheckoutViewModelAspect(this.selector); - - final R Function(CheckoutState state) selector; - - bool hasChanged(CheckoutState previous, CheckoutState next) { - return selector(previous) != selector(next); - } -} - -CheckoutStatus _checkoutViewModelSelectStatus(CheckoutState state) => state.status; -final _checkoutViewModelStatusAspect = _CheckoutViewModelAspect( - _checkoutViewModelSelectStatus, -); - -Object? _checkoutViewModelSelectShippingAddress(CheckoutState state) => state.shippingAddress; -final _checkoutViewModelShippingAddressAspect = _CheckoutViewModelAspect( - _checkoutViewModelSelectShippingAddress, -); - -String? _checkoutViewModelSelectErrorMessage(CheckoutState state) => state.errorMessage; -final _checkoutViewModelErrorMessageAspect = _CheckoutViewModelAspect( - _checkoutViewModelSelectErrorMessage, -); - -String? _checkoutViewModelSelectOrderId(CheckoutState state) => state.orderId; -final _checkoutViewModelOrderIdAspect = _CheckoutViewModelAspect( - _checkoutViewModelSelectOrderId, -); - -String? _checkoutViewModelSelectCouponCode(CheckoutState state) => state.couponCode; -final _checkoutViewModelCouponCodeAspect = _CheckoutViewModelAspect( - _checkoutViewModelSelectCouponCode, -); - -Object? _checkoutViewModelSelectQuote(CheckoutState state) => state.quote; -final _checkoutViewModelQuoteAspect = _CheckoutViewModelAspect( - _checkoutViewModelSelectQuote, -); - -bool _checkoutViewModelSelectIsQuoteLoading(CheckoutState state) => state.isQuoteLoading; -final _checkoutViewModelIsQuoteLoadingAspect = _CheckoutViewModelAspect( - _checkoutViewModelSelectIsQuoteLoading, -); - -/// Generated base class for CheckoutViewModel. +/// Base class generated for CheckoutViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class CheckoutViewModel extends $CheckoutViewModel { @@ -71,14 +26,12 @@ abstract class $CheckoutViewModel extends ViewModelBase state.count); /// ``` class _$CheckoutViewModelProxy { _$CheckoutViewModelProxy(this._context); @@ -88,66 +41,98 @@ class _$CheckoutViewModelProxy { CheckoutState get value { return CheckoutViewModelScope.of(_context).value; } +} - CheckoutStatus get status { - return CheckoutViewModelScope.of( - _context, - aspect: _checkoutViewModelStatusAspect, - ).state.status; - } +/// Rebuilds when a selected CheckoutState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class CheckoutViewModelSelector extends StatefulWidget { + const CheckoutViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); - Object? get shippingAddress { - return CheckoutViewModelScope.of( - _context, - aspect: _checkoutViewModelShippingAddressAspect, - ).state.shippingAddress; - } + final R Function(CheckoutState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _CheckoutViewModelSelectorState(); +} + +class _CheckoutViewModelSelectorState extends State> { + CheckoutViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; - String? get errorMessage { - return CheckoutViewModelScope.of( - _context, - aspect: _checkoutViewModelErrorMessageAspect, - ).state.errorMessage; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = CheckoutViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); } - String? get orderId { - return CheckoutViewModelScope.of( - _context, - aspect: _checkoutViewModelOrderIdAspect, - ).state.orderId; + @override + void didUpdateWidget(CheckoutViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); } - String? get couponCode { - return CheckoutViewModelScope.of( - _context, - aspect: _checkoutViewModelCouponCodeAspect, - ).state.couponCode; + void _onViewModelStateChanged() { + _selectCurrent(); } - Object? get quote { - return CheckoutViewModelScope.of( - _context, - aspect: _checkoutViewModelQuoteAspect, - ).state.quote; + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } } - bool get isQuoteLoading { - return CheckoutViewModelScope.of( - _context, - aspect: _checkoutViewModelIsQuoteLoadingAspect, - ).state.isQuoteLoading; + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); } - R select(R Function(CheckoutState state) selector) { - final aspect = _CheckoutViewModelAspect(selector); - return selector(CheckoutViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides CheckoutViewModel to descendants and owns it by default. +/// Creates and provides CheckoutViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// CheckoutViewModelScope( @@ -157,12 +142,13 @@ class _$CheckoutViewModelProxy { /// ) /// ``` class CheckoutViewModelScope extends StatefulWidget { - /// Creates an owned CheckoutViewModel from typed args. + /// Creates and owns CheckoutViewModel from typed args. const CheckoutViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned CheckoutViewModel without disposing it. @@ -171,27 +157,33 @@ class CheckoutViewModelScope extends StatefulWidget { required CheckoutViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final CheckoutViewModelArgs Function(BuildContext context)? args; final CheckoutViewModel Function(BuildContext context, CheckoutViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final CheckoutViewModel? value; final Widget child; - /// Reads CheckoutViewModel without subscribing the caller to state changes. + /// Reads CheckoutViewModel without rebuilding when state changes. static CheckoutViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_CheckoutViewModelInherited>() - ?.widget as _CheckoutViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_CheckoutViewModelInstance>() + ?.widget as _CheckoutViewModelInstance?; if (scope == null) throw StateError('No CheckoutViewModelScope found in context.'); return scope.viewModel; } - /// Watches CheckoutViewModel and optionally subscribes to one generated aspect. - static CheckoutViewModel of(BuildContext context, {_CheckoutViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_CheckoutViewModelInherited>( - aspect: aspect, - ); + static CheckoutViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_CheckoutViewModelInstance>(); + if (scope == null) throw StateError('No CheckoutViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches CheckoutViewModel and rebuilds when state changes. + static CheckoutViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_CheckoutViewModelInherited>(); if (scope == null) throw StateError('No CheckoutViewModelScope found in context.'); return scope.viewModel; } @@ -202,13 +194,21 @@ class CheckoutViewModelScope extends StatefulWidget { class _CheckoutViewModelScopeState extends State { CheckoutViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -219,14 +219,17 @@ class _CheckoutViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - CheckoutViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - CheckoutViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -256,6 +259,7 @@ class _CheckoutViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -264,16 +268,28 @@ class _CheckoutViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(CheckoutViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('CheckoutViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -292,41 +308,41 @@ class _CheckoutViewModelScopeState extends State { if (viewModel == null) { throw StateError('CheckoutViewModelScope built before its view model was initialized.'); } - return _CheckoutViewModelInherited( + return _CheckoutViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _CheckoutViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _CheckoutViewModelInherited extends InheritedModel<_CheckoutViewModelAspect> { - const _CheckoutViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _CheckoutViewModelInstance extends InheritedWidget { + const _CheckoutViewModelInstance({required this.viewModel, required super.child}); final CheckoutViewModel viewModel; - final CheckoutState state; - /// Requires CheckoutState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_CheckoutViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_CheckoutViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _CheckoutViewModelInherited extends InheritedWidget { + const _CheckoutViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final CheckoutViewModel viewModel; + final CheckoutState state; @override - bool updateShouldNotifyDependent( - _CheckoutViewModelInherited oldWidget, - Set<_CheckoutViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_CheckoutViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from CheckoutViewModel. +/// Handles one-shot effects from CheckoutViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -353,7 +369,7 @@ class _CheckoutViewModelListenerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = CheckoutViewModelScope.read(context); + final nextViewModel = CheckoutViewModelScope._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; @@ -374,7 +390,7 @@ class _CheckoutViewModelListenerState extends State { Widget build(BuildContext context) => widget.child; } -/// Generated BuildContext helpers for CheckoutViewModel. +/// BuildContext helpers generated for CheckoutViewModel. /// /// ```dart /// final vm = context.readCheckoutViewModel(); diff --git a/examples/shopping_app/lib/features/demo_cart/view_models/demo_cart_api_view_model.g.dart b/examples/shopping_app/lib/features/demo_cart/view_models/demo_cart_api_view_model.g.dart index d2d656ce..d2388433 100644 --- a/examples/shopping_app/lib/features/demo_cart/view_models/demo_cart_api_view_model.g.dart +++ b/examples/shopping_app/lib/features/demo_cart/view_models/demo_cart_api_view_model.g.dart @@ -13,34 +13,9 @@ part of 'demo_cart_api_view_model.dart'; -final class _DemoCartApiViewModelAspect { - const _DemoCartApiViewModelAspect(this.selector); - - final R Function(DemoCartState state) selector; - - bool hasChanged(DemoCartState previous, DemoCartState next) { - return selector(previous) != selector(next); - } -} - -DemoCartStatus _demoCartApiViewModelSelectStatus(DemoCartState state) => state.status; -final _demoCartApiViewModelStatusAspect = _DemoCartApiViewModelAspect( - _demoCartApiViewModelSelectStatus, -); - -List _demoCartApiViewModelSelectCarts(DemoCartState state) => state.carts; -final _demoCartApiViewModelCartsAspect = _DemoCartApiViewModelAspect>( - _demoCartApiViewModelSelectCarts, -); - -String? _demoCartApiViewModelSelectErrorMessage(DemoCartState state) => state.errorMessage; -final _demoCartApiViewModelErrorMessageAspect = _DemoCartApiViewModelAspect( - _demoCartApiViewModelSelectErrorMessage, -); - -/// Generated base class for DemoCartApiViewModel. +/// Base class generated for DemoCartApiViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class DemoCartApiViewModel extends $DemoCartApiViewModel { @@ -51,14 +26,12 @@ abstract class $DemoCartApiViewModel extends ViewModelBase state.count); /// ``` class _$DemoCartApiViewModelProxy { _$DemoCartApiViewModelProxy(this._context); @@ -68,38 +41,98 @@ class _$DemoCartApiViewModelProxy { DemoCartState get value { return DemoCartApiViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected DemoCartState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class DemoCartApiViewModelSelector extends StatefulWidget { + const DemoCartApiViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(DemoCartState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _DemoCartApiViewModelSelectorState(); +} - DemoCartStatus get status { - return DemoCartApiViewModelScope.of( - _context, - aspect: _demoCartApiViewModelStatusAspect, - ).state.status; +class _DemoCartApiViewModelSelectorState extends State> { + DemoCartApiViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = DemoCartApiViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); } - List get carts { - return DemoCartApiViewModelScope.of( - _context, - aspect: _demoCartApiViewModelCartsAspect, - ).state.carts; + @override + void didUpdateWidget(DemoCartApiViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); } - String? get errorMessage { - return DemoCartApiViewModelScope.of( - _context, - aspect: _demoCartApiViewModelErrorMessageAspect, - ).state.errorMessage; + void _onViewModelStateChanged() { + _selectCurrent(); } - R select(R Function(DemoCartState state) selector) { - final aspect = _DemoCartApiViewModelAspect(selector); - return selector(DemoCartApiViewModelScope.of(_context, aspect: aspect).value); + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides DemoCartApiViewModel to descendants and owns it by default. +/// Creates and provides DemoCartApiViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// DemoCartApiViewModelScope( @@ -109,12 +142,13 @@ class _$DemoCartApiViewModelProxy { /// ) /// ``` class DemoCartApiViewModelScope extends StatefulWidget { - /// Creates an owned DemoCartApiViewModel from typed args. + /// Creates and owns DemoCartApiViewModel from typed args. const DemoCartApiViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned DemoCartApiViewModel without disposing it. @@ -123,27 +157,33 @@ class DemoCartApiViewModelScope extends StatefulWidget { required DemoCartApiViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final DemoCartApiViewModelArgs Function(BuildContext context)? args; final DemoCartApiViewModel Function(BuildContext context, DemoCartApiViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final DemoCartApiViewModel? value; final Widget child; - /// Reads DemoCartApiViewModel without subscribing the caller to state changes. + /// Reads DemoCartApiViewModel without rebuilding when state changes. static DemoCartApiViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_DemoCartApiViewModelInherited>() - ?.widget as _DemoCartApiViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_DemoCartApiViewModelInstance>() + ?.widget as _DemoCartApiViewModelInstance?; if (scope == null) throw StateError('No DemoCartApiViewModelScope found in context.'); return scope.viewModel; } - /// Watches DemoCartApiViewModel and optionally subscribes to one generated aspect. - static DemoCartApiViewModel of(BuildContext context, {_DemoCartApiViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_DemoCartApiViewModelInherited>( - aspect: aspect, - ); + static DemoCartApiViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_DemoCartApiViewModelInstance>(); + if (scope == null) throw StateError('No DemoCartApiViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches DemoCartApiViewModel and rebuilds when state changes. + static DemoCartApiViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_DemoCartApiViewModelInherited>(); if (scope == null) throw StateError('No DemoCartApiViewModelScope found in context.'); return scope.viewModel; } @@ -154,13 +194,21 @@ class DemoCartApiViewModelScope extends StatefulWidget { class _DemoCartApiViewModelScopeState extends State { DemoCartApiViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -171,14 +219,17 @@ class _DemoCartApiViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - DemoCartApiViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - DemoCartApiViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -208,6 +259,7 @@ class _DemoCartApiViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -216,16 +268,28 @@ class _DemoCartApiViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(DemoCartApiViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('DemoCartApiViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -244,41 +308,41 @@ class _DemoCartApiViewModelScopeState extends State { if (viewModel == null) { throw StateError('DemoCartApiViewModelScope built before its view model was initialized.'); } - return _DemoCartApiViewModelInherited( + return _DemoCartApiViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _DemoCartApiViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _DemoCartApiViewModelInherited extends InheritedModel<_DemoCartApiViewModelAspect> { - const _DemoCartApiViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _DemoCartApiViewModelInstance extends InheritedWidget { + const _DemoCartApiViewModelInstance({required this.viewModel, required super.child}); final DemoCartApiViewModel viewModel; - final DemoCartState state; - /// Requires DemoCartState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_DemoCartApiViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_DemoCartApiViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _DemoCartApiViewModelInherited extends InheritedWidget { + const _DemoCartApiViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final DemoCartApiViewModel viewModel; + final DemoCartState state; @override - bool updateShouldNotifyDependent( - _DemoCartApiViewModelInherited oldWidget, - Set<_DemoCartApiViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_DemoCartApiViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from DemoCartApiViewModel. +/// Handles one-shot effects from DemoCartApiViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -305,7 +369,7 @@ class _DemoCartApiViewModelListenerState extends State widget.child; } -/// Generated BuildContext helpers for DemoCartApiViewModel. +/// BuildContext helpers generated for DemoCartApiViewModel. /// /// ```dart /// final vm = context.readDemoCartApiViewModel(); diff --git a/examples/shopping_app/lib/features/orders/view_models/order_tracking_view_model.g.dart b/examples/shopping_app/lib/features/orders/view_models/order_tracking_view_model.g.dart index ea411027..62f9f01f 100644 --- a/examples/shopping_app/lib/features/orders/view_models/order_tracking_view_model.g.dart +++ b/examples/shopping_app/lib/features/orders/view_models/order_tracking_view_model.g.dart @@ -13,19 +13,9 @@ part of 'order_tracking_view_model.dart'; -final class _OrderTrackingViewModelAspect { - const _OrderTrackingViewModelAspect(this.selector); - - final R Function(OrderTrackingState state) selector; - - bool hasChanged(OrderTrackingState previous, OrderTrackingState next) { - return selector(previous) != selector(next); - } -} - -/// Generated base class for OrderTrackingViewModel. +/// Base class generated for OrderTrackingViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class OrderTrackingViewModel extends $OrderTrackingViewModel { @@ -36,14 +26,12 @@ abstract class $OrderTrackingViewModel extends ViewModelBase state.count); /// ``` class _$OrderTrackingViewModelProxy { _$OrderTrackingViewModelProxy(this._context); @@ -53,17 +41,98 @@ class _$OrderTrackingViewModelProxy { OrderTrackingState get value { return OrderTrackingViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected OrderTrackingState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class OrderTrackingViewModelSelector extends StatefulWidget { + const OrderTrackingViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(OrderTrackingState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _OrderTrackingViewModelSelectorState(); +} + +class _OrderTrackingViewModelSelectorState extends State> { + OrderTrackingViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = OrderTrackingViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget(OrderTrackingViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } + + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); + } - R select(R Function(OrderTrackingState state) selector) { - final aspect = _OrderTrackingViewModelAspect(selector); - return selector(OrderTrackingViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides OrderTrackingViewModel to descendants and owns it by default. +/// Creates and provides OrderTrackingViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// OrderTrackingViewModelScope( @@ -73,12 +142,13 @@ class _$OrderTrackingViewModelProxy { /// ) /// ``` class OrderTrackingViewModelScope extends StatefulWidget { - /// Creates an owned OrderTrackingViewModel from typed args. + /// Creates and owns OrderTrackingViewModel from typed args. const OrderTrackingViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned OrderTrackingViewModel without disposing it. @@ -87,27 +157,33 @@ class OrderTrackingViewModelScope extends StatefulWidget { required OrderTrackingViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final OrderTrackingViewModelArgs Function(BuildContext context)? args; final OrderTrackingViewModel Function(BuildContext context, OrderTrackingViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final OrderTrackingViewModel? value; final Widget child; - /// Reads OrderTrackingViewModel without subscribing the caller to state changes. + /// Reads OrderTrackingViewModel without rebuilding when state changes. static OrderTrackingViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_OrderTrackingViewModelInherited>() - ?.widget as _OrderTrackingViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_OrderTrackingViewModelInstance>() + ?.widget as _OrderTrackingViewModelInstance?; if (scope == null) throw StateError('No OrderTrackingViewModelScope found in context.'); return scope.viewModel; } - /// Watches OrderTrackingViewModel and optionally subscribes to one generated aspect. - static OrderTrackingViewModel of(BuildContext context, {_OrderTrackingViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_OrderTrackingViewModelInherited>( - aspect: aspect, - ); + static OrderTrackingViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_OrderTrackingViewModelInstance>(); + if (scope == null) throw StateError('No OrderTrackingViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches OrderTrackingViewModel and rebuilds when state changes. + static OrderTrackingViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_OrderTrackingViewModelInherited>(); if (scope == null) throw StateError('No OrderTrackingViewModelScope found in context.'); return scope.viewModel; } @@ -118,13 +194,21 @@ class OrderTrackingViewModelScope extends StatefulWidget { class _OrderTrackingViewModelScopeState extends State { OrderTrackingViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -135,14 +219,17 @@ class _OrderTrackingViewModelScopeState extends State> { - const _OrderTrackingViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _OrderTrackingViewModelInstance extends InheritedWidget { + const _OrderTrackingViewModelInstance({required this.viewModel, required super.child}); final OrderTrackingViewModel viewModel; - final OrderTrackingState state; - /// Requires OrderTrackingState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_OrderTrackingViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_OrderTrackingViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _OrderTrackingViewModelInherited extends InheritedWidget { + const _OrderTrackingViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final OrderTrackingViewModel viewModel; + final OrderTrackingState state; @override - bool updateShouldNotifyDependent( - _OrderTrackingViewModelInherited oldWidget, - Set<_OrderTrackingViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_OrderTrackingViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from OrderTrackingViewModel. +/// Handles one-shot effects from OrderTrackingViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -269,7 +369,7 @@ class _OrderTrackingViewModelListenerState extends State widget.child; } -/// Generated BuildContext helpers for OrderTrackingViewModel. +/// BuildContext helpers generated for OrderTrackingViewModel. /// /// ```dart /// final vm = context.readOrderTrackingViewModel(); diff --git a/examples/shopping_app/lib/features/orders/view_models/orders_view_model.g.dart b/examples/shopping_app/lib/features/orders/view_models/orders_view_model.g.dart index 59b64681..5415fc56 100644 --- a/examples/shopping_app/lib/features/orders/view_models/orders_view_model.g.dart +++ b/examples/shopping_app/lib/features/orders/view_models/orders_view_model.g.dart @@ -13,29 +13,9 @@ part of 'orders_view_model.dart'; -final class _OrdersViewModelAspect { - const _OrdersViewModelAspect(this.selector); - - final R Function(OrdersState state) selector; - - bool hasChanged(OrdersState previous, OrdersState next) { - return selector(previous) != selector(next); - } -} - -List _ordersViewModelSelectOrders(OrdersState state) => state.orders; -final _ordersViewModelOrdersAspect = _OrdersViewModelAspect>( - _ordersViewModelSelectOrders, -); - -bool _ordersViewModelSelectIsLoading(OrdersState state) => state.isLoading; -final _ordersViewModelIsLoadingAspect = _OrdersViewModelAspect( - _ordersViewModelSelectIsLoading, -); - -/// Generated base class for OrdersViewModel. +/// Base class generated for OrdersViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class OrdersViewModel extends $OrdersViewModel { @@ -46,14 +26,12 @@ abstract class $OrdersViewModel extends ViewModelBase state.count); /// ``` class _$OrdersViewModelProxy { _$OrdersViewModelProxy(this._context); @@ -63,31 +41,98 @@ class _$OrdersViewModelProxy { OrdersState get value { return OrdersViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected OrdersState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class OrdersViewModelSelector extends StatefulWidget { + const OrdersViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(OrdersState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _OrdersViewModelSelectorState(); +} + +class _OrdersViewModelSelectorState extends State> { + OrdersViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = OrdersViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget(OrdersViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } - List get orders { - return OrdersViewModelScope.of( - _context, - aspect: _ordersViewModelOrdersAspect, - ).state.orders; + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } } - bool get isLoading { - return OrdersViewModelScope.of( - _context, - aspect: _ordersViewModelIsLoadingAspect, - ).state.isLoading; + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); } - R select(R Function(OrdersState state) selector) { - final aspect = _OrdersViewModelAspect(selector); - return selector(OrdersViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides OrdersViewModel to descendants and owns it by default. +/// Creates and provides OrdersViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// OrdersViewModelScope( @@ -97,12 +142,13 @@ class _$OrdersViewModelProxy { /// ) /// ``` class OrdersViewModelScope extends StatefulWidget { - /// Creates an owned OrdersViewModel from typed args. + /// Creates and owns OrdersViewModel from typed args. const OrdersViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned OrdersViewModel without disposing it. @@ -111,27 +157,33 @@ class OrdersViewModelScope extends StatefulWidget { required OrdersViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final OrdersViewModelArgs Function(BuildContext context)? args; final OrdersViewModel Function(BuildContext context, OrdersViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final OrdersViewModel? value; final Widget child; - /// Reads OrdersViewModel without subscribing the caller to state changes. + /// Reads OrdersViewModel without rebuilding when state changes. static OrdersViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_OrdersViewModelInherited>() - ?.widget as _OrdersViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_OrdersViewModelInstance>() + ?.widget as _OrdersViewModelInstance?; if (scope == null) throw StateError('No OrdersViewModelScope found in context.'); return scope.viewModel; } - /// Watches OrdersViewModel and optionally subscribes to one generated aspect. - static OrdersViewModel of(BuildContext context, {_OrdersViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_OrdersViewModelInherited>( - aspect: aspect, - ); + static OrdersViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_OrdersViewModelInstance>(); + if (scope == null) throw StateError('No OrdersViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches OrdersViewModel and rebuilds when state changes. + static OrdersViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_OrdersViewModelInherited>(); if (scope == null) throw StateError('No OrdersViewModelScope found in context.'); return scope.viewModel; } @@ -142,13 +194,21 @@ class OrdersViewModelScope extends StatefulWidget { class _OrdersViewModelScopeState extends State { OrdersViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -159,14 +219,17 @@ class _OrdersViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - OrdersViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - OrdersViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -196,6 +259,7 @@ class _OrdersViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -204,16 +268,28 @@ class _OrdersViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(OrdersViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('OrdersViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -232,41 +308,41 @@ class _OrdersViewModelScopeState extends State { if (viewModel == null) { throw StateError('OrdersViewModelScope built before its view model was initialized.'); } - return _OrdersViewModelInherited( + return _OrdersViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _OrdersViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _OrdersViewModelInherited extends InheritedModel<_OrdersViewModelAspect> { - const _OrdersViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _OrdersViewModelInstance extends InheritedWidget { + const _OrdersViewModelInstance({required this.viewModel, required super.child}); final OrdersViewModel viewModel; - final OrdersState state; - /// Requires OrdersState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_OrdersViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_OrdersViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _OrdersViewModelInherited extends InheritedWidget { + const _OrdersViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final OrdersViewModel viewModel; + final OrdersState state; @override - bool updateShouldNotifyDependent( - _OrdersViewModelInherited oldWidget, - Set<_OrdersViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_OrdersViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from OrdersViewModel. +/// Handles one-shot effects from OrdersViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -293,7 +369,7 @@ class _OrdersViewModelListenerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = OrdersViewModelScope.read(context); + final nextViewModel = OrdersViewModelScope._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; @@ -314,7 +390,7 @@ class _OrdersViewModelListenerState extends State { Widget build(BuildContext context) => widget.child; } -/// Generated BuildContext helpers for OrdersViewModel. +/// BuildContext helpers generated for OrdersViewModel. /// /// ```dart /// final vm = context.readOrdersViewModel(); diff --git a/examples/shopping_app/lib/features/product_detail/view_models/product_detail_view_model.g.dart b/examples/shopping_app/lib/features/product_detail/view_models/product_detail_view_model.g.dart index d2312d50..9810d4d9 100644 --- a/examples/shopping_app/lib/features/product_detail/view_models/product_detail_view_model.g.dart +++ b/examples/shopping_app/lib/features/product_detail/view_models/product_detail_view_model.g.dart @@ -13,44 +13,9 @@ part of 'product_detail_view_model.dart'; -final class _ProductDetailViewModelAspect { - const _ProductDetailViewModelAspect(this.selector); - - final R Function(ProductDetailState state) selector; - - bool hasChanged(ProductDetailState previous, ProductDetailState next) { - return selector(previous) != selector(next); - } -} - -int? _productDetailViewModelSelectProductId(ProductDetailState state) => state.productId; -final _productDetailViewModelProductIdAspect = _ProductDetailViewModelAspect( - _productDetailViewModelSelectProductId, -); - -ProductDetailStatus _productDetailViewModelSelectStatus(ProductDetailState state) => state.status; -final _productDetailViewModelStatusAspect = _ProductDetailViewModelAspect( - _productDetailViewModelSelectStatus, -); - -List _productDetailViewModelSelectReviews(ProductDetailState state) => state.reviews; -final _productDetailViewModelReviewsAspect = _ProductDetailViewModelAspect>( - _productDetailViewModelSelectReviews, -); - -List _productDetailViewModelSelectRecommendations(ProductDetailState state) => state.recommendations; -final _productDetailViewModelRecommendationsAspect = _ProductDetailViewModelAspect>( - _productDetailViewModelSelectRecommendations, -); - -String? _productDetailViewModelSelectErrorMessage(ProductDetailState state) => state.errorMessage; -final _productDetailViewModelErrorMessageAspect = _ProductDetailViewModelAspect( - _productDetailViewModelSelectErrorMessage, -); - -/// Generated base class for ProductDetailViewModel. +/// Base class generated for ProductDetailViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class ProductDetailViewModel extends $ProductDetailViewModel { @@ -61,14 +26,12 @@ abstract class $ProductDetailViewModel extends ViewModelBase state.count); /// ``` class _$ProductDetailViewModelProxy { _$ProductDetailViewModelProxy(this._context); @@ -78,52 +41,98 @@ class _$ProductDetailViewModelProxy { ProductDetailState get value { return ProductDetailViewModelScope.of(_context).value; } +} - int? get productId { - return ProductDetailViewModelScope.of( - _context, - aspect: _productDetailViewModelProductIdAspect, - ).state.productId; +/// Rebuilds when a selected ProductDetailState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class ProductDetailViewModelSelector extends StatefulWidget { + const ProductDetailViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(ProductDetailState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _ProductDetailViewModelSelectorState(); +} + +class _ProductDetailViewModelSelectorState extends State> { + ProductDetailViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = ProductDetailViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); } - ProductDetailStatus get status { - return ProductDetailViewModelScope.of( - _context, - aspect: _productDetailViewModelStatusAspect, - ).state.status; + @override + void didUpdateWidget(ProductDetailViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); } - List get reviews { - return ProductDetailViewModelScope.of( - _context, - aspect: _productDetailViewModelReviewsAspect, - ).state.reviews; + void _onViewModelStateChanged() { + _selectCurrent(); } - List get recommendations { - return ProductDetailViewModelScope.of( - _context, - aspect: _productDetailViewModelRecommendationsAspect, - ).state.recommendations; + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } } - String? get errorMessage { - return ProductDetailViewModelScope.of( - _context, - aspect: _productDetailViewModelErrorMessageAspect, - ).state.errorMessage; + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); } - R select(R Function(ProductDetailState state) selector) { - final aspect = _ProductDetailViewModelAspect(selector); - return selector(ProductDetailViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides ProductDetailViewModel to descendants and owns it by default. +/// Creates and provides ProductDetailViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// ProductDetailViewModelScope( @@ -133,12 +142,13 @@ class _$ProductDetailViewModelProxy { /// ) /// ``` class ProductDetailViewModelScope extends StatefulWidget { - /// Creates an owned ProductDetailViewModel from typed args. + /// Creates and owns ProductDetailViewModel from typed args. const ProductDetailViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned ProductDetailViewModel without disposing it. @@ -147,27 +157,33 @@ class ProductDetailViewModelScope extends StatefulWidget { required ProductDetailViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final ProductDetailViewModelArgs Function(BuildContext context)? args; final ProductDetailViewModel Function(BuildContext context, ProductDetailViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final ProductDetailViewModel? value; final Widget child; - /// Reads ProductDetailViewModel without subscribing the caller to state changes. + /// Reads ProductDetailViewModel without rebuilding when state changes. static ProductDetailViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_ProductDetailViewModelInherited>() - ?.widget as _ProductDetailViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_ProductDetailViewModelInstance>() + ?.widget as _ProductDetailViewModelInstance?; if (scope == null) throw StateError('No ProductDetailViewModelScope found in context.'); return scope.viewModel; } - /// Watches ProductDetailViewModel and optionally subscribes to one generated aspect. - static ProductDetailViewModel of(BuildContext context, {_ProductDetailViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_ProductDetailViewModelInherited>( - aspect: aspect, - ); + static ProductDetailViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ProductDetailViewModelInstance>(); + if (scope == null) throw StateError('No ProductDetailViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches ProductDetailViewModel and rebuilds when state changes. + static ProductDetailViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ProductDetailViewModelInherited>(); if (scope == null) throw StateError('No ProductDetailViewModelScope found in context.'); return scope.viewModel; } @@ -178,13 +194,21 @@ class ProductDetailViewModelScope extends StatefulWidget { class _ProductDetailViewModelScopeState extends State { ProductDetailViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -195,14 +219,17 @@ class _ProductDetailViewModelScopeState extends State> { - const _ProductDetailViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _ProductDetailViewModelInstance extends InheritedWidget { + const _ProductDetailViewModelInstance({required this.viewModel, required super.child}); final ProductDetailViewModel viewModel; - final ProductDetailState state; - /// Requires ProductDetailState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_ProductDetailViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_ProductDetailViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _ProductDetailViewModelInherited extends InheritedWidget { + const _ProductDetailViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final ProductDetailViewModel viewModel; + final ProductDetailState state; @override - bool updateShouldNotifyDependent( - _ProductDetailViewModelInherited oldWidget, - Set<_ProductDetailViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_ProductDetailViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from ProductDetailViewModel. +/// Handles one-shot effects from ProductDetailViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -329,7 +369,7 @@ class _ProductDetailViewModelListenerState extends State widget.child; } -/// Generated BuildContext helpers for ProductDetailViewModel. +/// BuildContext helpers generated for ProductDetailViewModel. /// /// ```dart /// final vm = context.readProductDetailViewModel(); diff --git a/examples/shopping_app/lib/features/products/view_models/products_view_model.g.dart b/examples/shopping_app/lib/features/products/view_models/products_view_model.g.dart index 23f87285..2f4abf87 100644 --- a/examples/shopping_app/lib/features/products/view_models/products_view_model.g.dart +++ b/examples/shopping_app/lib/features/products/view_models/products_view_model.g.dart @@ -13,49 +13,9 @@ part of 'products_view_model.dart'; -final class _ProductsViewModelAspect { - const _ProductsViewModelAspect(this.selector); - - final R Function(ProductsState state) selector; - - bool hasChanged(ProductsState previous, ProductsState next) { - return selector(previous) != selector(next); - } -} - -List _productsViewModelSelectProducts(ProductsState state) => state.products; -final _productsViewModelProductsAspect = _ProductsViewModelAspect>( - _productsViewModelSelectProducts, -); - -ProductsStatus _productsViewModelSelectStatus(ProductsState state) => state.status; -final _productsViewModelStatusAspect = _ProductsViewModelAspect( - _productsViewModelSelectStatus, -); - -String? _productsViewModelSelectErrorMessage(ProductsState state) => state.errorMessage; -final _productsViewModelErrorMessageAspect = _ProductsViewModelAspect( - _productsViewModelSelectErrorMessage, -); - -String? _productsViewModelSelectSelectedCategory(ProductsState state) => state.selectedCategory; -final _productsViewModelSelectedCategoryAspect = _ProductsViewModelAspect( - _productsViewModelSelectSelectedCategory, -); - -String _productsViewModelSelectSearchQuery(ProductsState state) => state.searchQuery; -final _productsViewModelSearchQueryAspect = _ProductsViewModelAspect( - _productsViewModelSelectSearchQuery, -); - -ProductSortOption _productsViewModelSelectSortOption(ProductsState state) => state.sortOption; -final _productsViewModelSortOptionAspect = _ProductsViewModelAspect( - _productsViewModelSelectSortOption, -); - -/// Generated base class for ProductsViewModel. +/// Base class generated for ProductsViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class ProductsViewModel extends $ProductsViewModel { @@ -66,14 +26,12 @@ abstract class $ProductsViewModel extends ViewModelBase state.count); /// ``` class _$ProductsViewModelProxy { _$ProductsViewModelProxy(this._context); @@ -83,59 +41,98 @@ class _$ProductsViewModelProxy { ProductsState get value { return ProductsViewModelScope.of(_context).value; } +} - List get products { - return ProductsViewModelScope.of( - _context, - aspect: _productsViewModelProductsAspect, - ).state.products; - } +/// Rebuilds when a selected ProductsState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class ProductsViewModelSelector extends StatefulWidget { + const ProductsViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(ProductsState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _ProductsViewModelSelectorState(); +} + +class _ProductsViewModelSelectorState extends State> { + ProductsViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; - ProductsStatus get status { - return ProductsViewModelScope.of( - _context, - aspect: _productsViewModelStatusAspect, - ).state.status; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = ProductsViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); } - String? get errorMessage { - return ProductsViewModelScope.of( - _context, - aspect: _productsViewModelErrorMessageAspect, - ).state.errorMessage; + @override + void didUpdateWidget(ProductsViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); } - String? get selectedCategory { - return ProductsViewModelScope.of( - _context, - aspect: _productsViewModelSelectedCategoryAspect, - ).state.selectedCategory; + void _onViewModelStateChanged() { + _selectCurrent(); } - String get searchQuery { - return ProductsViewModelScope.of( - _context, - aspect: _productsViewModelSearchQueryAspect, - ).state.searchQuery; + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } } - ProductSortOption get sortOption { - return ProductsViewModelScope.of( - _context, - aspect: _productsViewModelSortOptionAspect, - ).state.sortOption; + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); } - R select(R Function(ProductsState state) selector) { - final aspect = _ProductsViewModelAspect(selector); - return selector(ProductsViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides ProductsViewModel to descendants and owns it by default. +/// Creates and provides ProductsViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// ProductsViewModelScope( @@ -145,12 +142,13 @@ class _$ProductsViewModelProxy { /// ) /// ``` class ProductsViewModelScope extends StatefulWidget { - /// Creates an owned ProductsViewModel from typed args. + /// Creates and owns ProductsViewModel from typed args. const ProductsViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned ProductsViewModel without disposing it. @@ -159,27 +157,33 @@ class ProductsViewModelScope extends StatefulWidget { required ProductsViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final ProductsViewModelArgs Function(BuildContext context)? args; final ProductsViewModel Function(BuildContext context, ProductsViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final ProductsViewModel? value; final Widget child; - /// Reads ProductsViewModel without subscribing the caller to state changes. + /// Reads ProductsViewModel without rebuilding when state changes. static ProductsViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_ProductsViewModelInherited>() - ?.widget as _ProductsViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_ProductsViewModelInstance>() + ?.widget as _ProductsViewModelInstance?; if (scope == null) throw StateError('No ProductsViewModelScope found in context.'); return scope.viewModel; } - /// Watches ProductsViewModel and optionally subscribes to one generated aspect. - static ProductsViewModel of(BuildContext context, {_ProductsViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_ProductsViewModelInherited>( - aspect: aspect, - ); + static ProductsViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ProductsViewModelInstance>(); + if (scope == null) throw StateError('No ProductsViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches ProductsViewModel and rebuilds when state changes. + static ProductsViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ProductsViewModelInherited>(); if (scope == null) throw StateError('No ProductsViewModelScope found in context.'); return scope.viewModel; } @@ -190,13 +194,21 @@ class ProductsViewModelScope extends StatefulWidget { class _ProductsViewModelScopeState extends State { ProductsViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -207,14 +219,17 @@ class _ProductsViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - ProductsViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - ProductsViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -244,6 +259,7 @@ class _ProductsViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -252,16 +268,28 @@ class _ProductsViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(ProductsViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('ProductsViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -280,41 +308,41 @@ class _ProductsViewModelScopeState extends State { if (viewModel == null) { throw StateError('ProductsViewModelScope built before its view model was initialized.'); } - return _ProductsViewModelInherited( + return _ProductsViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _ProductsViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _ProductsViewModelInherited extends InheritedModel<_ProductsViewModelAspect> { - const _ProductsViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _ProductsViewModelInstance extends InheritedWidget { + const _ProductsViewModelInstance({required this.viewModel, required super.child}); final ProductsViewModel viewModel; - final ProductsState state; - /// Requires ProductsState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_ProductsViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_ProductsViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _ProductsViewModelInherited extends InheritedWidget { + const _ProductsViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final ProductsViewModel viewModel; + final ProductsState state; @override - bool updateShouldNotifyDependent( - _ProductsViewModelInherited oldWidget, - Set<_ProductsViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_ProductsViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from ProductsViewModel. +/// Handles one-shot effects from ProductsViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -341,7 +369,7 @@ class _ProductsViewModelListenerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = ProductsViewModelScope.read(context); + final nextViewModel = ProductsViewModelScope._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; @@ -362,7 +390,7 @@ class _ProductsViewModelListenerState extends State { Widget build(BuildContext context) => widget.child; } -/// Generated BuildContext helpers for ProductsViewModel. +/// BuildContext helpers generated for ProductsViewModel. /// /// ```dart /// final vm = context.readProductsViewModel(); diff --git a/examples/shopping_app/lib/features/support/view_models/shopping_chat_view_model.g.dart b/examples/shopping_app/lib/features/support/view_models/shopping_chat_view_model.g.dart index ccb4813f..b1a5db7e 100644 --- a/examples/shopping_app/lib/features/support/view_models/shopping_chat_view_model.g.dart +++ b/examples/shopping_app/lib/features/support/view_models/shopping_chat_view_model.g.dart @@ -13,19 +13,9 @@ part of 'shopping_chat_view_model.dart'; -final class _ShoppingChatViewModelAspect { - const _ShoppingChatViewModelAspect(this.selector); - - final R Function(ChatState state) selector; - - bool hasChanged(ChatState previous, ChatState next) { - return selector(previous) != selector(next); - } -} - -/// Generated base class for ShoppingChatViewModel. +/// Base class generated for ShoppingChatViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class ShoppingChatViewModel extends $ShoppingChatViewModel { @@ -36,14 +26,12 @@ abstract class $ShoppingChatViewModel extends ViewModelBase state.count); /// ``` class _$ShoppingChatViewModelProxy { _$ShoppingChatViewModelProxy(this._context); @@ -53,17 +41,98 @@ class _$ShoppingChatViewModelProxy { ChatState get value { return ShoppingChatViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected ChatState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class ShoppingChatViewModelSelector extends StatefulWidget { + const ShoppingChatViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(ChatState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _ShoppingChatViewModelSelectorState(); +} + +class _ShoppingChatViewModelSelectorState extends State> { + ShoppingChatViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = ShoppingChatViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget(ShoppingChatViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } + + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } + } + + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); + } - R select(R Function(ChatState state) selector) { - final aspect = _ShoppingChatViewModelAspect(selector); - return selector(ShoppingChatViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides ShoppingChatViewModel to descendants and owns it by default. +/// Creates and provides ShoppingChatViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// ShoppingChatViewModelScope( @@ -73,12 +142,13 @@ class _$ShoppingChatViewModelProxy { /// ) /// ``` class ShoppingChatViewModelScope extends StatefulWidget { - /// Creates an owned ShoppingChatViewModel from typed args. + /// Creates and owns ShoppingChatViewModel from typed args. const ShoppingChatViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned ShoppingChatViewModel without disposing it. @@ -87,27 +157,33 @@ class ShoppingChatViewModelScope extends StatefulWidget { required ShoppingChatViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final ShoppingChatViewModelArgs Function(BuildContext context)? args; final ShoppingChatViewModel Function(BuildContext context, ShoppingChatViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final ShoppingChatViewModel? value; final Widget child; - /// Reads ShoppingChatViewModel without subscribing the caller to state changes. + /// Reads ShoppingChatViewModel without rebuilding when state changes. static ShoppingChatViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_ShoppingChatViewModelInherited>() - ?.widget as _ShoppingChatViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_ShoppingChatViewModelInstance>() + ?.widget as _ShoppingChatViewModelInstance?; if (scope == null) throw StateError('No ShoppingChatViewModelScope found in context.'); return scope.viewModel; } - /// Watches ShoppingChatViewModel and optionally subscribes to one generated aspect. - static ShoppingChatViewModel of(BuildContext context, {_ShoppingChatViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_ShoppingChatViewModelInherited>( - aspect: aspect, - ); + static ShoppingChatViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ShoppingChatViewModelInstance>(); + if (scope == null) throw StateError('No ShoppingChatViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches ShoppingChatViewModel and rebuilds when state changes. + static ShoppingChatViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ShoppingChatViewModelInherited>(); if (scope == null) throw StateError('No ShoppingChatViewModelScope found in context.'); return scope.viewModel; } @@ -118,13 +194,21 @@ class ShoppingChatViewModelScope extends StatefulWidget { class _ShoppingChatViewModelScopeState extends State { ShoppingChatViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -135,14 +219,17 @@ class _ShoppingChatViewModelScopeState extends State if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - ShoppingChatViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - ShoppingChatViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -172,6 +259,7 @@ class _ShoppingChatViewModelScopeState extends State final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -180,16 +268,28 @@ class _ShoppingChatViewModelScopeState extends State _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(ShoppingChatViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('ShoppingChatViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -208,41 +308,41 @@ class _ShoppingChatViewModelScopeState extends State if (viewModel == null) { throw StateError('ShoppingChatViewModelScope built before its view model was initialized.'); } - return _ShoppingChatViewModelInherited( + return _ShoppingChatViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _ShoppingChatViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _ShoppingChatViewModelInherited extends InheritedModel<_ShoppingChatViewModelAspect> { - const _ShoppingChatViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _ShoppingChatViewModelInstance extends InheritedWidget { + const _ShoppingChatViewModelInstance({required this.viewModel, required super.child}); final ShoppingChatViewModel viewModel; - final ChatState state; - /// Requires ChatState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_ShoppingChatViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_ShoppingChatViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _ShoppingChatViewModelInherited extends InheritedWidget { + const _ShoppingChatViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final ShoppingChatViewModel viewModel; + final ChatState state; @override - bool updateShouldNotifyDependent( - _ShoppingChatViewModelInherited oldWidget, - Set<_ShoppingChatViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_ShoppingChatViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from ShoppingChatViewModel. +/// Handles one-shot effects from ShoppingChatViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -269,7 +369,7 @@ class _ShoppingChatViewModelListenerState extends State widget.child; } -/// Generated BuildContext helpers for ShoppingChatViewModel. +/// BuildContext helpers generated for ShoppingChatViewModel. /// /// ```dart /// final vm = context.readShoppingChatViewModel(); diff --git a/examples/shopping_app/lib/features/wishlist/view_models/wishlist_view_model.g.dart b/examples/shopping_app/lib/features/wishlist/view_models/wishlist_view_model.g.dart index a5945bc2..a7c794d6 100644 --- a/examples/shopping_app/lib/features/wishlist/view_models/wishlist_view_model.g.dart +++ b/examples/shopping_app/lib/features/wishlist/view_models/wishlist_view_model.g.dart @@ -13,29 +13,9 @@ part of 'wishlist_view_model.dart'; -final class _WishlistViewModelAspect { - const _WishlistViewModelAspect(this.selector); - - final R Function(WishlistState state) selector; - - bool hasChanged(WishlistState previous, WishlistState next) { - return selector(previous) != selector(next); - } -} - -List _wishlistViewModelSelectItems(WishlistState state) => state.items; -final _wishlistViewModelItemsAspect = _WishlistViewModelAspect>( - _wishlistViewModelSelectItems, -); - -bool _wishlistViewModelSelectIsLoading(WishlistState state) => state.isLoading; -final _wishlistViewModelIsLoadingAspect = _WishlistViewModelAspect( - _wishlistViewModelSelectIsLoading, -); - -/// Generated base class for WishlistViewModel. +/// Base class generated for WishlistViewModel. /// -/// Extend this class in the user-authored ViewModel and forward typed args: +/// Extend it from your ViewModel and forward typed args: /// /// ```dart /// final class WishlistViewModel extends $WishlistViewModel { @@ -46,14 +26,12 @@ abstract class $WishlistViewModel extends ViewModelBase state.count); /// ``` class _$WishlistViewModelProxy { _$WishlistViewModelProxy(this._context); @@ -63,31 +41,98 @@ class _$WishlistViewModelProxy { WishlistState get value { return WishlistViewModelScope.of(_context).value; } +} + +/// Rebuilds when a selected WishlistState value changes. +/// +/// Use this for widgets that depend on one field or derived value. +class WishlistViewModelSelector extends StatefulWidget { + const WishlistViewModelSelector({ + super.key, + required this.selector, + required this.builder, + this.child, + this.equals, + }); + + final R Function(WishlistState state) selector; + final Widget Function(BuildContext context, R value, Widget? child) builder; + final Widget? child; + final bool Function(R previous, R next)? equals; + + @override + State> createState() => _WishlistViewModelSelectorState(); +} + +class _WishlistViewModelSelectorState extends State> { + WishlistViewModel? _viewModel; + R? _selected; + bool _hasSelected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextViewModel = WishlistViewModelScope._watchInstance(context); + if (identical(_viewModel, nextViewModel)) return; + _viewModel?.removeListener(_onViewModelStateChanged); + _viewModel = nextViewModel; + nextViewModel.addListener(_onViewModelStateChanged); + _selectCurrent(force: true); + } + + @override + void didUpdateWidget(WishlistViewModelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + _selectCurrent(force: true); + } - List get items { - return WishlistViewModelScope.of( - _context, - aspect: _wishlistViewModelItemsAspect, - ).state.items; + void _onViewModelStateChanged() { + _selectCurrent(); + } + + void _selectCurrent({bool force = false}) { + final viewModel = _viewModel; + if (viewModel == null) return; + final nextSelected = widget.selector(viewModel.value); + if (!_hasSelected) { + _selected = nextSelected; + _hasSelected = true; + return; + } + final previousSelected = _selected as R; + final equals = widget.equals; + final unchanged = equals == null + ? previousSelected == nextSelected + : equals(previousSelected, nextSelected); + if (!force && unchanged) return; + if (force || !mounted) { + _selected = nextSelected; + } else { + setState(() { + _selected = nextSelected; + }); + } } - bool get isLoading { - return WishlistViewModelScope.of( - _context, - aspect: _wishlistViewModelIsLoadingAspect, - ).state.isLoading; + @override + void dispose() { + _viewModel?.removeListener(_onViewModelStateChanged); + super.dispose(); } - R select(R Function(WishlistState state) selector) { - final aspect = _WishlistViewModelAspect(selector); - return selector(WishlistViewModelScope.of(_context, aspect: aspect).value); + @override + Widget build(BuildContext context) { + if (!_hasSelected) { + _selectCurrent(force: true); + } + return widget.builder(context, _selected as R, widget.child); } } -/// Provides WishlistViewModel to descendants and owns it by default. +/// Creates and provides WishlistViewModel to descendants. /// -/// Use the default constructor when this scope should create and dispose the -/// ViewModel. Use `.value` only for externally owned ViewModels. +/// The default constructor owns the ViewModel. Use `.value` for externally +/// owned ViewModels. /// /// ```dart /// WishlistViewModelScope( @@ -97,12 +142,13 @@ class _$WishlistViewModelProxy { /// ) /// ``` class WishlistViewModelScope extends StatefulWidget { - /// Creates an owned WishlistViewModel from typed args. + /// Creates and owns WishlistViewModel from typed args. const WishlistViewModelScope({ super.key, required this.args, required this.create, required this.child, + this.identity, }) : value = null; /// Provides an externally owned WishlistViewModel without disposing it. @@ -111,27 +157,33 @@ class WishlistViewModelScope extends StatefulWidget { required WishlistViewModel this.value, required this.child, }) : args = null, - create = null; + create = null, + identity = null; final WishlistViewModelArgs Function(BuildContext context)? args; final WishlistViewModel Function(BuildContext context, WishlistViewModelArgs args)? create; + final Object? Function(BuildContext context)? identity; final WishlistViewModel? value; final Widget child; - /// Reads WishlistViewModel without subscribing the caller to state changes. + /// Reads WishlistViewModel without rebuilding when state changes. static WishlistViewModel read(BuildContext context) { final scope = context - .getElementForInheritedWidgetOfExactType<_WishlistViewModelInherited>() - ?.widget as _WishlistViewModelInherited?; + .getElementForInheritedWidgetOfExactType<_WishlistViewModelInstance>() + ?.widget as _WishlistViewModelInstance?; if (scope == null) throw StateError('No WishlistViewModelScope found in context.'); return scope.viewModel; } - /// Watches WishlistViewModel and optionally subscribes to one generated aspect. - static WishlistViewModel of(BuildContext context, {_WishlistViewModelAspect? aspect}) { - final scope = context.dependOnInheritedWidgetOfExactType<_WishlistViewModelInherited>( - aspect: aspect, - ); + static WishlistViewModel _watchInstance(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_WishlistViewModelInstance>(); + if (scope == null) throw StateError('No WishlistViewModelScope found in context.'); + return scope.viewModel; + } + + /// Watches WishlistViewModel and rebuilds when state changes. + static WishlistViewModel of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_WishlistViewModelInherited>(); if (scope == null) throw StateError('No WishlistViewModelScope found in context.'); return scope.viewModel; } @@ -142,13 +194,21 @@ class WishlistViewModelScope extends StatefulWidget { class _WishlistViewModelScopeState extends State { WishlistViewModel? _viewModel; + Object? _identity; bool _ownsViewModel = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_viewModel == null) { - _replaceViewModel(_resolveViewModel(), ownsViewModel: widget.value == null, notify: false); + final external = widget.value; + if (external != null) { + _replaceViewModel(external, ownsViewModel: false, notify: false); + return; + } + final nextIdentity = widget.identity?.call(context); + if (_viewModel == null || nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true, notify: false); } } @@ -159,14 +219,17 @@ class _WishlistViewModelScopeState extends State { if (external != null) { _replaceViewModel(external, ownsViewModel: false); } else if (oldWidget.value != null) { + _identity = widget.identity?.call(context); _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } else { + final nextIdentity = widget.identity?.call(context); + if (nextIdentity != _identity) { + _identity = nextIdentity; + _replaceViewModel(_createOwnedViewModel(), ownsViewModel: true); + } } } - WishlistViewModel _resolveViewModel() { - return widget.value ?? _createOwnedViewModel(); - } - WishlistViewModel _createOwnedViewModel() { final argsFactory = widget.args; final create = widget.create; @@ -196,6 +259,7 @@ class _WishlistViewModelScopeState extends State { final previous = _viewModel; if (identical(previous, nextViewModel)) { _ownsViewModel = ownsViewModel; + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); return; } @@ -204,16 +268,28 @@ class _WishlistViewModelScopeState extends State { _viewModel = nextViewModel; _ownsViewModel = ownsViewModel; nextViewModel.addListener(_onViewModelStateChanged); - if (ownsViewModel) { - scheduleMicrotask(() { - if (mounted && identical(_viewModel, nextViewModel)) { - nextViewModel.init(); - } - }); - } + _scheduleInit(nextViewModel); if (notify && mounted) setState(() {}); } + void _scheduleInit(WishlistViewModel viewModel) { + scheduleMicrotask(() async { + if (!mounted || !identical(_viewModel, viewModel)) return; + try { + await viewModel.init(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'dust state', + context: ErrorDescription('WishlistViewModelScope init failed'), + ), + ); + } + }); + } + void _onViewModelStateChanged() { if (mounted) setState(() {}); } @@ -232,41 +308,41 @@ class _WishlistViewModelScopeState extends State { if (viewModel == null) { throw StateError('WishlistViewModelScope built before its view model was initialized.'); } - return _WishlistViewModelInherited( + return _WishlistViewModelInstance( viewModel: viewModel, - state: viewModel.value, - child: widget.child, + child: _WishlistViewModelInherited( + viewModel: viewModel, + state: viewModel.value, + child: widget.child, + ), ); } } -class _WishlistViewModelInherited extends InheritedModel<_WishlistViewModelAspect> { - const _WishlistViewModelInherited({required this.viewModel, required this.state, required super.child}); +class _WishlistViewModelInstance extends InheritedWidget { + const _WishlistViewModelInstance({required this.viewModel, required super.child}); final WishlistViewModel viewModel; - final WishlistState state; - /// Requires WishlistState to implement == and hashCode. Without value equality, - /// every emitted state is treated as changed and granular rebuilds degrade to - /// full dependent subtree rebuilds. @override - bool updateShouldNotify(_WishlistViewModelInherited oldWidget) => state != oldWidget.state; + bool updateShouldNotify(_WishlistViewModelInstance oldWidget) { + return !identical(viewModel, oldWidget.viewModel); + } +} + +class _WishlistViewModelInherited extends InheritedWidget { + const _WishlistViewModelInherited({required this.viewModel, required this.state, required super.child}); + + final WishlistViewModel viewModel; + final WishlistState state; @override - bool updateShouldNotifyDependent( - _WishlistViewModelInherited oldWidget, - Set<_WishlistViewModelAspect> dependencies, - ) { - for (final aspect in dependencies) { - if (aspect.hasChanged(oldWidget.state, state)) { - return true; - } - } - return false; + bool updateShouldNotify(_WishlistViewModelInherited oldWidget) { + return !identical(viewModel, oldWidget.viewModel) || state != oldWidget.state; } } -/// Listens to one-shot effects from WishlistViewModel. +/// Handles one-shot effects from WishlistViewModel. /// /// Effects are delivered without changing state and do not rebuild `child`. /// @@ -293,7 +369,7 @@ class _WishlistViewModelListenerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextViewModel = WishlistViewModelScope.read(context); + final nextViewModel = WishlistViewModelScope._watchInstance(context); if (_viewModel == nextViewModel) return; _sub?.cancel(); _viewModel = nextViewModel; @@ -314,7 +390,7 @@ class _WishlistViewModelListenerState extends State { Widget build(BuildContext context) => widget.child; } -/// Generated BuildContext helpers for WishlistViewModel. +/// BuildContext helpers generated for WishlistViewModel. /// /// ```dart /// final vm = context.readWishlistViewModel(); diff --git a/examples/shopping_app/lib/main.dart b/examples/shopping_app/lib/main.dart index 6ead65e7..4a71d93a 100644 --- a/examples/shopping_app/lib/main.dart +++ b/examples/shopping_app/lib/main.dart @@ -69,65 +69,82 @@ class _ShoppingAppState extends State { ), create: (context, args) => AppViewModel(args), child: Builder( - builder: (context) => AuthViewModelScope( - args: (context) => AuthViewModelArgs( - repository: context.readAppViewModel().args.repository, - storage: context.readAppViewModel().args.storage, - observer: observer, - ), - create: (context, args) => AuthViewModel(args), - child: CartViewModelScope( - args: (context) => CartViewModelArgs(observer: observer), - create: (context, args) => CartViewModel(args), - child: CheckoutViewModelScope( - args: (context) => CheckoutViewModelArgs( + builder: (context) => BnbViewModelScope( + args: (context) => BnbViewModelArgs(observer: observer), + create: (context, args) => BnbViewModel(args), + child: HomeViewModelScope( + args: (context) => HomeViewModelArgs( + repository: context.readAppViewModel().args.repository, + observer: observer, + ), + create: (context, args) => HomeViewModel(args), + child: AuthViewModelScope( + args: (context) => AuthViewModelArgs( repository: context.readAppViewModel().args.repository, + storage: context.readAppViewModel().args.storage, observer: observer, ), - create: (context, args) => CheckoutViewModel(args), - child: OrdersViewModelScope( - args: (context) => OrdersViewModelArgs(observer: observer), - create: (context, args) => OrdersViewModel(args), - child: OrderTrackingViewModelScope( - args: (context) => OrderTrackingViewModelArgs( + create: (context, args) => AuthViewModel(args), + child: CartViewModelScope( + args: (context) => CartViewModelArgs(observer: observer), + create: (context, args) => CartViewModel(args), + child: CheckoutViewModelScope( + args: (context) => CheckoutViewModelArgs( repository: context.readAppViewModel().args.repository, observer: observer, ), - create: (context, args) => OrderTrackingViewModel(args), - child: ProductsViewModelScope( - args: (context) => ProductsViewModelArgs( - repository: context.readAppViewModel().args.repository, - observer: observer, - ), - create: (context, args) => ProductsViewModel(args), - child: ProductDetailViewModelScope( - args: (context) => ProductDetailViewModelArgs( + create: (context, args) => CheckoutViewModel(args), + child: OrdersViewModelScope( + args: (context) => OrdersViewModelArgs(observer: observer), + create: (context, args) => OrdersViewModel(args), + child: OrderTrackingViewModelScope( + args: (context) => OrderTrackingViewModelArgs( repository: context.readAppViewModel().args.repository, observer: observer, ), - create: (context, args) => ProductDetailViewModel(args), - child: WishlistViewModelScope( - args: (context) => WishlistViewModelArgs( - storage: context.readAppViewModel().args.storage, + create: (context, args) => OrderTrackingViewModel(args), + child: ProductsViewModelScope( + args: (context) => ProductsViewModelArgs( + repository: + context.readAppViewModel().args.repository, observer: observer, ), - create: (context, args) => WishlistViewModel(args), - child: DemoCartApiViewModelScope( - args: (context) => DemoCartApiViewModelArgs( + create: (context, args) => ProductsViewModel(args), + child: ProductDetailViewModelScope( + args: (context) => ProductDetailViewModelArgs( repository: context.readAppViewModel().args.repository, observer: observer, ), - create: (context, args) => DemoCartApiViewModel(args), - child: ShoppingChatViewModelScope( - args: (context) => ShoppingChatViewModelArgs( - repository: - context.readAppViewModel().args.repository, + create: (context, args) => + ProductDetailViewModel(args), + child: WishlistViewModelScope( + args: (context) => WishlistViewModelArgs( + storage: context.readAppViewModel().args.storage, observer: observer, ), - create: (context, args) => - ShoppingChatViewModel(args), - child: const _ShoppingRouterApp(), + create: (context, args) => WishlistViewModel(args), + child: DemoCartApiViewModelScope( + args: (context) => DemoCartApiViewModelArgs( + repository: + context.readAppViewModel().args.repository, + observer: observer, + ), + create: (context, args) => + DemoCartApiViewModel(args), + child: ShoppingChatViewModelScope( + args: (context) => ShoppingChatViewModelArgs( + repository: context + .readAppViewModel() + .args + .repository, + observer: observer, + ), + create: (context, args) => + ShoppingChatViewModel(args), + child: const _ShoppingRouterApp(), + ), + ), ), ), ), diff --git a/examples/shopping_app/test/app_view_model_test.dart b/examples/shopping_app/test/app_view_model_test.dart new file mode 100644 index 00000000..fc2ba214 --- /dev/null +++ b/examples/shopping_app/test/app_view_model_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shopping_app/core/view_models/app_view_model.dart'; + +import 'support/fake_shopping_repository.dart'; + +final class ControlledHomeViewModel extends HomeViewModel { + ControlledHomeViewModel() + : super(HomeViewModelArgs(repository: FakeShoppingRepository())); + + final loads = >[]; + + @override + Future loadData() { + final completer = Completer(); + loads.add(completer); + return completer.future; + } +} + +const homeData = HomePageData( + featuredProducts: FakeShoppingRepository.products, + categories: ['bags', 'clothing'], +); + +void main() { + test('bnb view model selects tabs by type and index', () { + final viewModel = BnbViewModel(const BnbViewModelArgs()) + ..select(BnbTab.cart); + + expect(viewModel.state.currentTab, BnbTab.cart); + expect(viewModel.state.currentIndex, BnbTab.cart.index); + + viewModel.selectIndex(BnbTab.orders.index); + + expect(viewModel.state.currentTab, BnbTab.orders); + }); + + test('home view model loads page data through args repository', () async { + final viewModel = HomeViewModel( + HomeViewModelArgs(repository: FakeShoppingRepository()), + ); + + await viewModel.load(); + + expect(viewModel.state.hasData, isTrue); + expect(viewModel.data?.featuredProducts, FakeShoppingRepository.products); + expect(viewModel.data?.categories, const ['bags', 'clothing']); + }); + + testWidgets('home builder renders async lifecycle with previous data', ( + tester, + ) async { + final viewModel = ControlledHomeViewModel(); + + await tester.pumpWidget( + MaterialApp( + home: HomeViewModelScope.value( + value: viewModel, + child: HomeViewModelBuilder( + loading: (context) => const Text('loading'), + data: (context, data) => Text( + 'data:${data.featuredProducts.length}:refreshing:${viewModel.state.isRefreshing}', + ), + error: (context, error, previousData) => Text( + 'error:${previousData?.featuredProducts.length}', + ), + ), + ), + ), + ); + + expect(find.text('loading'), findsOneWidget); + + await tester.pump(); + viewModel.loads.single.complete(homeData); + await tester.pump(); + await tester.pump(); + + expect(find.text('data:2:refreshing:false'), findsOneWidget); + + final refresh = viewModel.refresh(); + await tester.pump(); + + expect(find.text('data:2:refreshing:true'), findsOneWidget); + + viewModel.loads.last.completeError(StateError('failed')); + await refresh; + await tester.pump(); + await tester.pump(); + + expect(find.text('error:2'), findsOneWidget); + }); +} diff --git a/examples/shopping_app/test/state_scope_lifecycle_test.dart b/examples/shopping_app/test/state_scope_lifecycle_test.dart new file mode 100644 index 00000000..ae0e1b41 --- /dev/null +++ b/examples/shopping_app/test/state_scope_lifecycle_test.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shopping_app/core/data/shopping_repository.dart'; +import 'package:shopping_app/features/products/models/products_state.dart'; +import 'package:shopping_app/features/products/view_models/products_view_model.dart'; + +class MockRepository implements ShoppingRepository { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class TestProductsViewModel extends ProductsViewModel { + TestProductsViewModel(super.args); + + @override + Future onInit() async {} + + void emitForTest(ProductsState state) { + emit(state); + } +} + +class LifecycleProductsViewModel extends ProductsViewModel { + LifecycleProductsViewModel(super.args, this.label); + + final String label; + var disposeCalls = 0; + + @override + Future onInit() async { + emit(state.copyWith(searchQuery: label)); + } + + void emitForTest(ProductsState state) { + emit(state); + } + + @override + void dispose() { + disposeCalls += 1; + super.dispose(); + } +} + +class TestIdentityScope extends InheritedWidget { + const TestIdentityScope({ + required this.value, + required super.child, + super.key, + }); + + final String value; + + static String of(BuildContext context) { + final scope = + context.dependOnInheritedWidgetOfExactType(); + if (scope == null) throw StateError('No TestIdentityScope found.'); + return scope.value; + } + + @override + bool updateShouldNotify(TestIdentityScope oldWidget) { + return value != oldWidget.value; + } +} + +void main() { + testWidgets('selector resubscribes when value scope swaps view model', ( + tester, + ) async { + final first = TestProductsViewModel( + ProductsViewModelArgs(repository: MockRepository()), + ); + final second = TestProductsViewModel( + ProductsViewModelArgs(repository: MockRepository()), + ); + var selectorRebuilds = 0; + + Widget build(TestProductsViewModel viewModel) { + return MaterialApp( + home: ProductsViewModelScope.value( + value: viewModel, + child: ProductsViewModelSelector( + selector: (state) => state.searchQuery, + builder: (context, searchQuery, child) { + selectorRebuilds += 1; + return Text(searchQuery.isEmpty ? 'empty' : searchQuery); + }, + ), + ), + ); + } + + await tester.pumpWidget(build(first)); + + expect(find.text('empty'), findsOneWidget); + expect(selectorRebuilds, 1); + + first.emitForTest(first.state.copyWith(searchQuery: 'first')); + await tester.pump(); + + expect(find.text('first'), findsOneWidget); + expect(selectorRebuilds, 2); + + await tester.pumpWidget(build(second)); + await tester.pump(); + + expect(find.text('empty'), findsOneWidget); + expect(selectorRebuilds, 3); + + first.emitForTest(first.state.copyWith(searchQuery: 'stale')); + await tester.pump(); + + expect(find.text('stale'), findsNothing); + expect(selectorRebuilds, 3); + + second.emitForTest(second.state.copyWith(searchQuery: 'second')); + await tester.pump(); + + expect(find.text('second'), findsOneWidget); + expect(selectorRebuilds, 4); + }); + + testWidgets('owned scope disposes replaced view models once', ( + tester, + ) async { + final created = []; + + Widget build(String identity) { + return MaterialApp( + home: TestIdentityScope( + value: identity, + child: ProductsViewModelScope( + identity: TestIdentityScope.of, + args: (_) => ProductsViewModelArgs(repository: MockRepository()), + create: (context, args) { + final viewModel = LifecycleProductsViewModel( + args, + TestIdentityScope.of(context), + ); + created.add(viewModel); + return viewModel; + }, + child: ProductsViewModelSelector( + selector: (state) => state.searchQuery, + builder: (context, searchQuery, child) { + return Text(searchQuery); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(build('one')); + await tester.pump(); + await tester.pump(); + + expect(find.text('one'), findsOneWidget); + expect(created, hasLength(1)); + final first = created.single; + expect(first.disposeCalls, 0); + + await tester.pumpWidget(build('two')); + await tester.pump(); + await tester.pump(); + + expect(find.text('two'), findsOneWidget); + expect(find.text('one'), findsNothing); + expect(created, hasLength(2)); + expect(first.disposeCalls, 1); + final second = created.last; + expect(second.disposeCalls, 0); + + first.emitForTest(first.state.copyWith(searchQuery: 'stale')); + await tester.pump(); + + expect(find.text('stale'), findsNothing); + expect(find.text('two'), findsOneWidget); + expect(first.disposeCalls, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + expect(first.disposeCalls, 1); + expect(second.disposeCalls, 1); + }); +} diff --git a/examples/shopping_app/test/state_selector_test.dart b/examples/shopping_app/test/state_selector_test.dart index b4d10482..b0bec4b4 100644 --- a/examples/shopping_app/test/state_selector_test.dart +++ b/examples/shopping_app/test/state_selector_test.dart @@ -12,17 +12,57 @@ class MockRepository implements ShoppingRepository { class TestProductsViewModel extends ProductsViewModel { TestProductsViewModel(super.args); + @override + Future onInit() async {} + void emitForTest(ProductsState state) { emit(state); } + + void emitEffectForTest(Object effect) { + emitEffect(effect); + } +} + +class InitProductsViewModel extends ProductsViewModel { + InitProductsViewModel(super.args, this.label); + + final String label; + + @override + Future onInit() async { + emit(state.copyWith(searchQuery: '${state.searchQuery}$label')); + } +} + +class TestIdentityScope extends InheritedWidget { + const TestIdentityScope({ + required this.value, + required super.child, + super.key, + }); + + final String value; + + static String of(BuildContext context) { + final scope = + context.dependOnInheritedWidgetOfExactType(); + if (scope == null) throw StateError('No TestIdentityScope found.'); + return scope.value; + } + + @override + bool updateShouldNotify(TestIdentityScope oldWidget) { + return value != oldWidget.value; + } } void main() { - testWidgets('state aspects rebuild only when selected field changes', ( + testWidgets('selector rebuilds only when selected value changes', ( tester, ) async { - var selectRebuilds = 0; - var fieldRebuilds = 0; + var fullRebuilds = 0; + var selectorRebuilds = 0; final viewModel = TestProductsViewModel( ProductsViewModelArgs(repository: MockRepository()), ); @@ -35,18 +75,16 @@ void main() { children: [ Builder( builder: (context) { - final status = context.watchProductsViewModel().select( - (state) => state.status, - ); - selectRebuilds++; - return Text('Select status: $status'); + final state = context.watchProductsViewModel().value; + fullRebuilds++; + return Text('Full: ${state.status}'); }, ), - Builder( - builder: (context) { - final status = context.watchProductsViewModel().status; - fieldRebuilds++; - return Text('Field status: $status'); + ProductsViewModelSelector( + selector: (state) => state.status, + builder: (context, status, child) { + selectorRebuilds++; + return Text('Selected: $status'); }, ), ], @@ -55,25 +93,257 @@ void main() { ), ); - expect(find.text('Select status: ProductsStatus.initial'), findsOneWidget); - expect(find.text('Field status: ProductsStatus.initial'), findsOneWidget); - expect(selectRebuilds, 1); - expect(fieldRebuilds, 1); + expect(find.text('Full: ProductsStatus.initial'), findsOneWidget); + expect(find.text('Selected: ProductsStatus.initial'), findsOneWidget); + expect(fullRebuilds, 1); + expect(selectorRebuilds, 1); viewModel.emitForTest(viewModel.state.copyWith(searchQuery: 'backpack')); await tester.pump(); - expect(selectRebuilds, 1); - expect(fieldRebuilds, 1); + expect(fullRebuilds, 2); + expect(selectorRebuilds, 1); + + viewModel.emitForTest( + viewModel.state.copyWith(status: ProductsStatus.loading), + ); + await tester.pump(); + + expect(fullRebuilds, 3); + expect(selectorRebuilds, 2); + expect(find.text('Selected: ProductsStatus.loading'), findsOneWidget); + }); + + testWidgets('selector supports custom equality', (tester) async { + var selectorRebuilds = 0; + final viewModel = TestProductsViewModel( + ProductsViewModelArgs(repository: MockRepository()), + ); + + await tester.pumpWidget( + MaterialApp( + home: ProductsViewModelScope.value( + value: viewModel, + child: ProductsViewModelSelector( + selector: (state) => state.searchQuery, + equals: (previous, next) => previous.length == next.length, + builder: (context, searchQuery, child) { + selectorRebuilds++; + return Text(searchQuery); + }, + ), + ), + ), + ); + + expect(selectorRebuilds, 1); + + viewModel.emitForTest(viewModel.state.copyWith(searchQuery: 'aa')); + await tester.pump(); + + expect(selectorRebuilds, 2); + expect(find.text('aa'), findsOneWidget); + + viewModel.emitForTest(viewModel.state.copyWith(searchQuery: 'bb')); + await tester.pump(); + + expect(selectorRebuilds, 2); + expect(find.text('aa'), findsOneWidget); + + viewModel.emitForTest(viewModel.state.copyWith(searchQuery: 'ccc')); + await tester.pump(); + + expect(selectorRebuilds, 3); + expect(find.text('ccc'), findsOneWidget); + }); + + testWidgets('selector ignores many unrelated state changes', ( + tester, + ) async { + var selectorRebuilds = 0; + final viewModel = TestProductsViewModel( + ProductsViewModelArgs(repository: MockRepository()), + ); + + await tester.pumpWidget( + MaterialApp( + home: ProductsViewModelScope.value( + value: viewModel, + child: ProductsViewModelSelector( + selector: (state) => state.status, + builder: (context, status, child) { + selectorRebuilds += 1; + return Text(status.name); + }, + ), + ), + ), + ); + + expect(selectorRebuilds, 1); + + for (var i = 0; i < 50; i += 1) { + viewModel.emitForTest(viewModel.state.copyWith(searchQuery: 'q$i')); + await tester.pump(); + } + + expect(selectorRebuilds, 1); + expect(find.text(ProductsStatus.initial.name), findsOneWidget); + }); + + testWidgets('.value scope runs init once for external view model', ( + tester, + ) async { + final viewModel = InitProductsViewModel( + ProductsViewModelArgs(repository: MockRepository()), + 'external', + ); + + Widget build() { + return MaterialApp( + home: ProductsViewModelScope.value( + value: viewModel, + child: Builder( + builder: (context) { + final state = context.watchProductsViewModel().value; + return Text(state.searchQuery); + }, + ), + ), + ); + } + + await tester.pumpWidget(build()); + await tester.pump(); + await tester.pump(); + + expect(find.text('external'), findsOneWidget); + + await tester.pumpWidget(build()); + await tester.pump(); + await tester.pump(); + + expect(find.text('external'), findsOneWidget); + expect(find.text('externalexternal'), findsNothing); + }); + + testWidgets('owned scope recreates when identity changes', (tester) async { + Widget build(String identity) { + return MaterialApp( + home: TestIdentityScope( + value: identity, + child: ProductsViewModelScope( + identity: TestIdentityScope.of, + args: (_) => ProductsViewModelArgs(repository: MockRepository()), + create: (context, args) { + return InitProductsViewModel(args, TestIdentityScope.of(context)); + }, + child: ProductsViewModelSelector( + selector: (state) => state.searchQuery, + builder: (context, searchQuery, child) { + return Text(searchQuery); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(build('one')); + await tester.pump(); + await tester.pump(); + + expect(find.text('one'), findsOneWidget); + + await tester.pumpWidget(build('two')); + await tester.pump(); + await tester.pump(); + + expect(find.text('two'), findsOneWidget); + expect(find.text('one'), findsNothing); + }); + + testWidgets('listener resubscribes when value scope swaps view model', ( + tester, + ) async { + final first = TestProductsViewModel( + ProductsViewModelArgs(repository: MockRepository()), + ); + final second = TestProductsViewModel( + ProductsViewModelArgs(repository: MockRepository()), + ); + final effects = []; + + Widget build(TestProductsViewModel viewModel) { + return MaterialApp( + home: ProductsViewModelScope.value( + value: viewModel, + child: ProductsViewModelListener( + listener: (context, effect) => effects.add(effect), + child: const SizedBox.shrink(), + ), + ), + ); + } + + await tester.pumpWidget(build(first)); + await tester.pump(); + + first.emitEffectForTest('first'); + await tester.pump(); + + expect(effects, ['first']); + + await tester.pumpWidget(build(second)); + await tester.pump(); + + first.emitEffectForTest('stale'); + second.emitEffectForTest('second'); + await tester.pump(); + + expect(effects, ['first', 'second']); + }); + + testWidgets('listener does not rebuild child for state changes or effects', ( + tester, + ) async { + var childBuilds = 0; + final effects = []; + final viewModel = TestProductsViewModel( + ProductsViewModelArgs(repository: MockRepository()), + ); + + await tester.pumpWidget( + MaterialApp( + home: ProductsViewModelScope.value( + value: viewModel, + child: ProductsViewModelListener( + listener: (context, effect) => effects.add(effect), + child: Builder( + builder: (context) { + childBuilds += 1; + return const Text('listener child'); + }, + ), + ), + ), + ), + ); + + expect(childBuilds, 1); viewModel.emitForTest( viewModel.state.copyWith(status: ProductsStatus.loading), ); await tester.pump(); - expect(selectRebuilds, 2); - expect(fieldRebuilds, 2); - expect(find.text('Select status: ProductsStatus.loading'), findsOneWidget); - expect(find.text('Field status: ProductsStatus.loading'), findsOneWidget); + expect(childBuilds, 1); + expect(effects, isEmpty); + + viewModel.emitEffectForTest('toast'); + await tester.pump(); + + expect(childBuilds, 1); + expect(effects, ['toast']); }); } diff --git a/packages/dust_flutter/lib/src/state/annotations.dart b/packages/dust_flutter/lib/src/state/annotations.dart index c539334e..ad5c4c08 100644 --- a/packages/dust_flutter/lib/src/state/annotations.dart +++ b/packages/dust_flutter/lib/src/state/annotations.dart @@ -1,5 +1,14 @@ // coverage:ignore-file +/// Selects the generated ViewModel base shape. +enum ViewModelMode { + /// Synchronous state managed directly by the ViewModel. + sync, + + /// Async loaded data wrapped in generated async lifecycle state. + async, +} + /// Marks a class as a Dust view model generation target. /// /// Example: @@ -20,7 +29,12 @@ /// ``` class ViewModel { /// Creates metadata consumed by the Dust state generator. - const ViewModel({required this.state, this.args, this.initial}); + const ViewModel({ + required this.state, + this.args, + this.initial, + this.mode = ViewModelMode.sync, + }); /// Immutable state type managed by the generated view model base. final Type state; @@ -31,4 +45,7 @@ class ViewModel { /// Optional initial state expression for enums, imported states, or states /// without a default `const State()` constructor. final Object? initial; + + /// Generated ViewModel mode. + final ViewModelMode mode; } diff --git a/packages/dust_flutter/lib/src/state/view_model.dart b/packages/dust_flutter/lib/src/state/view_model.dart index bc25a9c2..09af28f6 100644 --- a/packages/dust_flutter/lib/src/state/view_model.dart +++ b/packages/dust_flutter/lib/src/state/view_model.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; /// Shared dependency bundle base for generated view models. base class ViewModelArgs { @@ -62,6 +61,115 @@ final class StateEffect { final Object value; } +/// Lifecycle state for generated async ViewModels. +sealed class AsyncState { + const AsyncState(); + + /// Whether a load or refresh is in progress. + bool get isLoading; + + /// Whether current visible data is available. + bool get hasData => false; + + /// Whether previous visible data is available. + bool get hasPreviousData => hasData; + + /// Whether visible data is being refreshed. + bool get isRefreshing => false; + + /// Current visible data, when available. + T? get data => null; + + /// Data preserved from the previous successful load, when available. + T? get previousData => data; + + /// Current load error, when available. + Object? get error => null; + + /// Stack trace for the current load failure, when available. + StackTrace? get stackTrace => null; +} + +/// No async load has started yet. +final class AsyncInitial extends AsyncState { + /// Creates initial async state. + const AsyncInitial(); + + @override + bool get isLoading => false; +} + +/// Async data is loading. +final class AsyncLoading extends AsyncState { + /// Creates loading async state. + const AsyncLoading({this.previousData, this.hasPreviousData = false}); + + @override + final T? previousData; + + @override + final bool hasPreviousData; + + @override + T? get data => previousData; + + @override + bool get hasData => hasPreviousData; + + @override + bool get isLoading => true; + + @override + bool get isRefreshing => hasPreviousData; +} + +/// Async data loaded successfully. +final class AsyncData extends AsyncState { + /// Creates data async state. + const AsyncData(this.data); + + @override + final T data; + + @override + bool get hasData => true; + + @override + bool get isLoading => false; +} + +/// Async load failed. +final class AsyncFailure extends AsyncState { + /// Creates failed async state. + const AsyncFailure( + this.error, + this.stackTrace, { + this.previousData, + this.hasPreviousData = false, + }); + + @override + final Object error; + + @override + final StackTrace stackTrace; + + @override + final T? previousData; + + @override + final bool hasPreviousData; + + @override + T? get data => previousData; + + @override + bool get hasData => hasPreviousData; + + @override + bool get isLoading => false; +} + /// Token used by view models to ignore stale async work. @immutable final class ViewModelActionToken { @@ -79,11 +187,13 @@ abstract class ViewModelBase extends ValueNotifier { /// Creates a view model with typed [args] and [initialState]. ViewModelBase(this.args, {required TState initialState}) - : super(initialState); + : _initialState = initialState, + super(initialState); /// Typed dependencies for this view model. final TArgs args; + final TState _initialState; final StreamController _effects = StreamController.broadcast(); final Map _actionVersions = {}; @@ -148,6 +258,13 @@ abstract class ViewModelBase _actionVersions[key] = (_actionVersions[key] ?? 0) + 1; } + /// Clears pending actions and returns state to the generated initial value. + void invalidateSelf() { + if (_isDisposed) return; + _actionVersions.clear(); + emit(_initialState); + } + /// Override for one-time initialization. @protected FutureOr onInit() {} @@ -177,114 +294,64 @@ abstract class ViewModelBase } } -/// Creates args for a generated view model scope. -typedef ViewModelArgsFactory = TArgs Function( - BuildContext context, -); - -/// Creates a view model for a generated scope. -typedef ViewModelFactory, - TArgs extends ViewModelArgs> - = TViewModel Function( - BuildContext context, - TArgs args, -); - -/// Generic owner used by generated scopes. -/// -/// Generated code should wrap this with typed APIs instead of exposing it -/// directly to app code. -class ViewModelOwner, - TArgs extends ViewModelArgs> extends StatefulWidget { - /// Creates an owner that constructs and disposes the view model. - const ViewModelOwner({ - required this.args, - required this.create, - required this.builder, - super.key, - this.debugName, - }) : value = null; - - /// Creates a provider for an externally owned view model. - const ViewModelOwner.value({ - required TViewModel this.value, - required this.builder, - super.key, - this.debugName, - }) : args = null, - create = null; - - /// Args factory for the owned constructor. - final ViewModelArgsFactory? args; - - /// Human-readable scope name used in dependency-injection errors. - final String? debugName; - - /// View model factory for the owned constructor. - final ViewModelFactory? create; - - /// External view model for `.value` usage. - final TViewModel? value; - - /// Builds the subtree with a ready view model. - final Widget Function(BuildContext context, TViewModel viewModel) builder; +/// Base class used by generated async Dust view model bases. +abstract class AsyncViewModelBase + extends ViewModelBase, TArgs> { + /// Creates an async view model. + AsyncViewModelBase(super.args) : super(initialState: AsyncInitial()); - @override - State> createState() => - _ViewModelOwnerState(); -} + static const Object _loadAction = Object(); -class _ViewModelOwnerState, - TArgs extends ViewModelArgs> - extends State> { - TViewModel? _owned; - - TViewModel get _viewModel { - final value = widget.value; - if (value != null) return value; - final owned = _owned; - if (owned == null) { - throw StateError('ViewModelOwner was read before initialization.'); - } - return owned; + /// Loads fresh data for this view model. + Future loadData(); + + /// Starts initial loading. + Future load() => _runLoad(preserveData: false); + + /// Reloads data while preserving visible data when present. + Future refresh() => _runLoad(preserveData: true); + + /// Retries after an error while preserving previous data when present. + Future retry() => refresh(); + + /// Current visible data. + TData? get data => state.data; + + /// Current or previous visible data. + TData? get visibleData { + if (state.hasData) return state.data; + if (state.hasPreviousData) return state.previousData; + return null; } @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (widget.value != null || _owned != null) return; - final argsFactory = widget.args; - final create = widget.create; - if (argsFactory == null || create == null) { - throw StateError('Owned ViewModelOwner requires args and create.'); - } - late final TViewModel created; + Future onInit() => load(); + + Future _runLoad({required bool preserveData}) async { + final token = beginAction(_loadAction); + final previousData = preserveData ? visibleData : null; + final hasPreviousData = + preserveData && (state.hasData || state.hasPreviousData); + emit( + AsyncLoading( + previousData: previousData, + hasPreviousData: hasPreviousData, + ), + ); try { - created = create(context, argsFactory(context)); - } catch (error, stackTrace) { - final ownerName = widget.debugName ?? 'ViewModelOwner<$TViewModel>'; - Error.throwWithStackTrace( - StateError( - '$ownerName failed to create its view model. Check the generated ' - 'scope args/create dependency injection. Original error: $error', + final nextData = await loadData(); + if (!isCurrentAction(token)) return; + emit(AsyncData(nextData)); + } on Object catch (error, stackTrace) { + if (!isCurrentAction(token)) return; + emit( + AsyncFailure( + error, + stackTrace, + previousData: previousData, + hasPreviousData: hasPreviousData, ), - stackTrace, ); } - _owned = created; - scheduleMicrotask(() { - if (mounted && identical(_owned, created)) { - unawaited(created.init()); - } - }); } - - @override - void dispose() { - _owned?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.builder(context, _viewModel); } diff --git a/packages/dust_flutter/lib/state.dart b/packages/dust_flutter/lib/state.dart index 364b4823..1273c2a2 100644 --- a/packages/dust_flutter/lib/state.dart +++ b/packages/dust_flutter/lib/state.dart @@ -1,9 +1,18 @@ /// Typed Flutter state management annotations and runtime for Dust. library; -export 'dart:async' show StreamSubscription, scheduleMicrotask; +export 'dart:async' show Future, StreamSubscription, scheduleMicrotask; export 'package:flutter/widgets.dart' - show BuildContext, InheritedModel, State, StatefulWidget, Widget; + show + BuildContext, + ErrorDescription, + FlutterError, + FlutterErrorDetails, + InheritedWidget, + State, + StatefulWidget, + StatelessWidget, + Widget; export 'src/state/annotations.dart'; export 'src/state/view_model.dart'; diff --git a/packages/dust_flutter/test/async_state_test.dart b/packages/dust_flutter/test/async_state_test.dart new file mode 100644 index 00000000..b50de19d --- /dev/null +++ b/packages/dust_flutter/test/async_state_test.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:dust_flutter/state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class TestArgs extends ViewModelArgs { + const TestArgs(); +} + +final class TestAsyncViewModel extends AsyncViewModelBase { + TestAsyncViewModel() : super(const TestArgs()); + + final loads = >[]; + + @override + Future loadData() { + final completer = Completer(); + loads.add(completer); + return completer.future; + } +} + +final class NullableAsyncViewModel extends AsyncViewModelBase { + NullableAsyncViewModel() : super(const TestArgs()); + + bool shouldFail = false; + + @override + Future loadData() async { + if (shouldFail) { + throw StateError('failed'); + } + return null; + } +} + +void main() { + test('load moves initial to data', () async { + final viewModel = TestAsyncViewModel(); + + final load = viewModel.load(); + expect(viewModel.state, isA>()); + + viewModel.loads.single.complete(7); + await load; + + expect(viewModel.state, isA>()); + expect(viewModel.data, 7); + expect(viewModel.visibleData, 7); + }); + + test('refresh preserves previous data on error', () async { + final viewModel = TestAsyncViewModel(); + + final load = viewModel.load(); + viewModel.loads.single.complete(7); + await load; + + final refresh = viewModel.refresh(); + expect(viewModel.state, isA>()); + expect(viewModel.state.isRefreshing, isTrue); + expect(viewModel.state.hasPreviousData, isTrue); + expect(viewModel.state.data, 7); + + viewModel.loads.last.completeError(StateError('failed')); + await refresh; + + expect(viewModel.state, isA>()); + expect(viewModel.state.hasPreviousData, isTrue); + expect(viewModel.state.data, 7); + expect(viewModel.state.previousData, 7); + expect(viewModel.state.error, isA()); + expect(viewModel.state.stackTrace, isA()); + }); + + test('stale load result is ignored', () async { + final viewModel = TestAsyncViewModel(); + + final first = viewModel.load(); + final second = viewModel.refresh(); + + viewModel.loads[1].complete(2); + await second; + + expect(viewModel.data, 2); + + viewModel.loads[0].complete(1); + await first; + + expect(viewModel.data, 2); + }); + + test('invalidateSelf clears async state and cancels stale load', () async { + final viewModel = TestAsyncViewModel(); + + final load = viewModel.load(); + viewModel.loads.single.complete(7); + await load; + + final refresh = viewModel.refresh(); + expect(viewModel.state.hasPreviousData, isTrue); + + viewModel.invalidateSelf(); + + expect(viewModel.state, isA>()); + expect(viewModel.visibleData, isNull); + + viewModel.loads.last.complete(9); + await refresh; + + expect(viewModel.state, isA>()); + expect(viewModel.visibleData, isNull); + }); + + test('nullable data is still present data', () async { + final viewModel = NullableAsyncViewModel(); + + await viewModel.load(); + + expect(viewModel.state, isA>()); + expect(viewModel.state.hasData, isTrue); + expect(viewModel.data, isNull); + + viewModel.shouldFail = true; + await viewModel.refresh(); + + expect(viewModel.state, isA>()); + expect(viewModel.state.hasPreviousData, isTrue); + expect(viewModel.state.previousData, isNull); + }); +} diff --git a/packages/dust_flutter/test/view_model_test.dart b/packages/dust_flutter/test/view_model_test.dart new file mode 100644 index 00000000..fd5ea0c7 --- /dev/null +++ b/packages/dust_flutter/test/view_model_test.dart @@ -0,0 +1,24 @@ +import 'package:dust_flutter/state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class TestArgs extends ViewModelArgs { + const TestArgs(); +} + +final class CounterViewModel extends ViewModelBase { + CounterViewModel() : super(const TestArgs(), initialState: 0); + + void setCount(int next) { + emit(next); + } +} + +void main() { + test('invalidateSelf resets sync state to initial state', () { + final viewModel = CounterViewModel() + ..setCount(3) + ..invalidateSelf(); + + expect(viewModel.state, 0); + }); +}