diff --git a/packages/fluorflow/example/lib/views/detail/detail_view.dart b/packages/fluorflow/example/lib/views/detail/detail_view.dart index 26bbaa6..0ff2f75 100644 --- a/packages/fluorflow/example/lib/views/detail/detail_view.dart +++ b/packages/fluorflow/example/lib/views/detail/detail_view.dart @@ -26,7 +26,12 @@ final class DetailView extends FluorFlowView { mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('Detail Page'), + Text('Count: ${viewModel.data}'), const SizedBox(height: 36), + ElevatedButton( + onPressed: viewModel.addOne, + child: const Text('plus one'), + ), ElevatedButton( onPressed: viewModel.back, child: const Text('Back'), diff --git a/packages/fluorflow/example/lib/views/detail/detail_viewmodel.dart b/packages/fluorflow/example/lib/views/detail/detail_viewmodel.dart index d293770..7e8d33e 100644 --- a/packages/fluorflow/example/lib/views/detail/detail_viewmodel.dart +++ b/packages/fluorflow/example/lib/views/detail/detail_viewmodel.dart @@ -1,12 +1,18 @@ +import 'dart:async'; + import 'package:example/app.bottom_sheets.dart'; import 'package:fluorflow/fluorflow.dart'; -final class DetailViewModel extends BaseViewModel { +final class DetailViewModel extends DataViewModel { final _navService = locator(); final _sheets = locator(); + DetailViewModel() : super(0); + void showBottomSheet() => _sheets.showGreetingBottomSheet(callback: () {}, onElement: (_) {}); void back() => _navService.back(); + + void addOne() => data += 1; } diff --git a/packages/fluorflow/lib/src/annotations/dialog_config.dart b/packages/fluorflow/lib/src/annotations/dialog_config.dart index 4126cdc..133b4fe 100644 --- a/packages/fluorflow/lib/src/annotations/dialog_config.dart +++ b/packages/fluorflow/lib/src/annotations/dialog_config.dart @@ -14,7 +14,7 @@ class DialogConfig { /// The route builder for the dialog. /// The routeBuilder defines the transition animations for the dialog. - /// If set to [RouteBuilder.custom], a [pageRouteBuilder] must be provided. + /// If a [pageRouteBuilder] is provided, the custom builder is used. final RouteBuilder routeBuilder; /// Customize the behaviour of a dialog / simple dialog with a [DialogConfig]. diff --git a/packages/fluorflow/lib/src/annotations/routable.dart b/packages/fluorflow/lib/src/annotations/routable.dart index 6eed8c2..ece33ba 100644 --- a/packages/fluorflow/lib/src/annotations/routable.dart +++ b/packages/fluorflow/lib/src/annotations/routable.dart @@ -9,6 +9,7 @@ import '../navigation/route_builder.dart'; /// The [path] parameter can be used to specify a custom path for the routable class. /// The [pageRouteBuilder] parameter can be used to specify a custom page route builder for the routable class. /// The [routeBuilder] parameter can be used to specify a custom route builder for the routable class. +/// When a [pageRouteBuilder] is provided, the [routeBuilder] is ignored. /// The [navigateToExtension] defines whether the method extension on the navigation service contains a navigateTo method. /// The [replaceWithExtension] defines whether the method extension on the navigation service contains a replaceWith method. /// The [rootToExtension] defines whether the method extension on the navigation service contains a rootTo method. diff --git a/packages/fluorflow/lib/src/navigation/route_builder.dart b/packages/fluorflow/lib/src/navigation/route_builder.dart index b9c272c..fbfd353 100644 --- a/packages/fluorflow/lib/src/navigation/route_builder.dart +++ b/packages/fluorflow/lib/src/navigation/route_builder.dart @@ -32,7 +32,4 @@ enum RouteBuilder { /// Zoom in transition. zoomIn, - - /// Custom transition. - custom, } diff --git a/packages/fluorflow/lib/src/viewmodels/base_viewmodel.dart b/packages/fluorflow/lib/src/viewmodels/base_viewmodel.dart index 49a809c..87a8515 100644 --- a/packages/fluorflow/lib/src/viewmodels/base_viewmodel.dart +++ b/packages/fluorflow/lib/src/viewmodels/base_viewmodel.dart @@ -81,6 +81,7 @@ abstract base class BaseViewModel extends ChangeNotifier implements ViewModel { /// reflected in the UI. @nonVirtual @override + @protected void notifyListeners() { if (!_disposed) { super.notifyListeners(); diff --git a/packages/fluorflow/lib/src/viewmodels/data_viewmodel.dart b/packages/fluorflow/lib/src/viewmodels/data_viewmodel.dart index c96b836..d782b34 100644 --- a/packages/fluorflow/lib/src/viewmodels/data_viewmodel.dart +++ b/packages/fluorflow/lib/src/viewmodels/data_viewmodel.dart @@ -1,14 +1,16 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'base_viewmodel.dart'; /// "Data" view model. Contains a data of type [TData] object that is observed for changes. /// This view model is used to manage complex data operations and to notify the UI when the data changes. -/// The data can be of any type and is stored in a [ValueNotifier]. Since the data -/// is initialized with a call, accessing the data before the initialize method is called -/// results in a state error. +/// The data can be of any type and is stored in a [ValueNotifier]. The data must be initialized +/// with a default value (synchronously) and can be changed at any time. To initialize data with +/// an asynchronous operation, the [initialize] method can be overridden. +/// +/// If the [initialize] method is overridden, it must call super to set the initialized flag. +/// Thus, if your async operation fails, the overwriting method is responsible to set the error +/// and still call initialize after the operation is done. /// /// It combines well with packages such as "freezed" to manage view state with /// sealed classes. @@ -30,8 +32,7 @@ import 'base_viewmodel.dart'; /// } /// /// final class HomeViewModel extends DataViewModel { -/// @override -/// FutureOr initializeData() => const LoadingState(); +/// HomeViewModel() : super(const LoadingState()); /// /// void loadData() async { /// await Future.delayed(const Duration(seconds: 2)); @@ -39,64 +40,66 @@ import 'base_viewmodel.dart'; /// } /// } /// ``` +/// +/// Example where data is loaded asynchronously: +/// ```dart +/// sealed class HomeState { +/// const HomeState(); +/// } +/// +/// final class LoadingState extends HomeState { +/// const LoadingState(); +/// } +/// +/// final class LoadedState extends HomeState { +/// final String data; +/// +/// const LoadedState(this.data); +/// } +/// +/// final class HomeViewModel extends DataViewModel { +/// HomeViewModel() : super(const LoadingState()); +/// +/// @override +/// FutureOr initialize() async { +/// await Future.delayed(const Duration(seconds: 2)); +/// data = const LoadedState('Hello, World!'); +/// await super.initialize(); +/// } +/// } +/// ``` abstract base class DataViewModel extends BaseViewModel { - late final ValueNotifier _data; + final ValueNotifier _data; + + /// Creates the data view model with initial data. + /// The data is required to initialize the [data] value + /// notifier with some default value. The flag [notifyOnDataChange] + /// indicates if the view model should notify listeners (by default) when the data changes. + /// If this is set to false, changes to the data field will not automatically trigger + /// a [notifyListeners] call. + DataViewModel(TData initialData, [bool notifyOnDataChange = true]) + : _data = ValueNotifier(initialData) { + if (notifyOnDataChange) { + _data.addListener(notifyListeners); + } + } /// Return the data notifier for the view model. This can be used /// to add additional listeners to data changes. - /// - /// If the view model is not initialized, a state error is thrown. @nonVirtual - ValueNotifier get dataNotifier => - initialized ? _data : (throw StateError('ViewModel is not initialized.')); + ValueNotifier get dataNotifier => _data; /// Get the current data value ([TData]). - /// - /// If the view model is not initialized, a state error is thrown. @nonVirtual - TData get data => initialized - ? _data.value - : (throw StateError('ViewModel is not initialized.')); + TData get data => _data.value; /// Set the data value ([TData]). - /// Setting the data will trigger a [notifyListeners] call. + /// Setting the data will trigger a [notifyListeners] call + /// if the view model was created with the [notifyOnDataChange] + /// flag set to true. @nonVirtual @protected set data(TData value) { _data.value = value; } - - /// Indicates whether the view model should notify listeners when the data changes. - /// This may be overwritten by subclasses to disable the notification. - @protected - bool get notifyOnDataChange => true; - - /// Initializes the data of the view model. It may return a Future or the data directly. - @protected - FutureOr initializeData(); - - /// Callback for error situations when initializing the data. - /// This also triggers the [onError] callback. - @protected - void onDataInitializeError(dynamic error) {} - - /// Initializes the data view model. - /// The data of the view model is initialized and a listener is added to the data - /// (if [notifyOnDataChange] is set). - /// - /// Subclasses that overwrite this method must call super to properly initialize the data. - @override - @mustCallSuper - FutureOr initialize() async { - try { - _data = ValueNotifier(await initializeData()); - if (notifyOnDataChange) { - _data.addListener(notifyListeners); - } - await super.initialize(); - } catch (e) { - error = e; - onDataInitializeError(e); - } - } } diff --git a/packages/fluorflow/test/viewmodels/data_viewmode_test.dart b/packages/fluorflow/test/viewmodels/data_viewmode_test.dart new file mode 100644 index 0000000..f809d7b --- /dev/null +++ b/packages/fluorflow/test/viewmodels/data_viewmode_test.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:fluorflow/src/viewmodels/data_viewmodel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _NormalViewModel extends DataViewModel { + _NormalViewModel() : super('Hello, World!'); + + @override + FutureOr initialize() { + data = 'Hello, brave new World!'; + return super.initialize(); + } +} + +final class _ErrorViewModel extends DataViewModel { + _ErrorViewModel() : super('Hello, World!'); + + @override + FutureOr initialize() async { + try { + throw StateError('Error'); + } catch (e) { + error = e; + } finally { + await super.initialize(); + } + } +} + +void main() { + group('DataViewModel', () { + test('should set the provided initial data.', () async { + final viewModel = _NormalViewModel(); + expect(viewModel.data, 'Hello, World!'); + }); + + test('should set the provided data of when initialized.', () async { + final viewModel = _NormalViewModel(); + await viewModel.initialize(); + expect(viewModel.data, 'Hello, brave new World!'); + }); + + test('should set initialized.', () async { + final viewModel = _NormalViewModel(); + expect(viewModel.initialized, false); + await viewModel.initialize(); + expect(viewModel.initialized, true); + }); + + test('should set initialized on error (when coded).', () async { + final viewModel = _ErrorViewModel(); + expect(viewModel.initialized, false); + await viewModel.initialize(); + expect(viewModel.initialized, true); + }); + + test('should attach listener on error.', () async { + final viewModel = _ErrorViewModel(); + await viewModel.initialize(); + // ignore: invalid_use_of_protected_member + expect(viewModel.dataNotifier.hasListeners, true); + }); + + test('should set error state when initialize data fails.', () async { + final viewModel = _ErrorViewModel(); + await viewModel.initialize(); + expect(viewModel.error, isA()); + }); + }); +} diff --git a/packages/fluorflow_generator/lib/src/builder/dialog_builder.dart b/packages/fluorflow_generator/lib/src/builder/dialog_builder.dart index 15999a0..d0b440d 100644 --- a/packages/fluorflow_generator/lib/src/builder/dialog_builder.dart +++ b/packages/fluorflow_generator/lib/src/builder/dialog_builder.dart @@ -79,10 +79,7 @@ class DialogBuilder implements Builder { RouteBuilder.noTransition), configAnnotation.read('pageRouteBuilder').isNull )) { - (RouteBuilder.custom, true) => throw InvalidGenerationSourceError( - 'You must provide a pageRouteBuilder when using a custom routeBuilder.', - element: dialogClass), - (RouteBuilder.custom, false) => refer( + (_, false) => refer( configAnnotation .read('pageRouteBuilder') .typeValue diff --git a/packages/fluorflow_generator/lib/src/builder/router_builder.dart b/packages/fluorflow_generator/lib/src/builder/router_builder.dart index 4a9feef..e4f9677 100644 --- a/packages/fluorflow_generator/lib/src/builder/router_builder.dart +++ b/packages/fluorflow_generator/lib/src/builder/router_builder.dart @@ -128,10 +128,7 @@ class RouterBuilder implements Builder { RouteBuilder.noTransition), annotation.read('pageRouteBuilder').isNull )) { - (RouteBuilder.custom, true) => throw InvalidGenerationSourceError( - 'You must provide a pageRouteBuilder when using a custom routeBuilder.', - element: element), - (RouteBuilder.custom, false) => refer( + (_, false) => refer( annotation .read('pageRouteBuilder') .typeValue diff --git a/packages/fluorflow_generator/test/builder/dialog_builder_test.dart b/packages/fluorflow_generator/test/builder/dialog_builder_test.dart index ad757c1..f6d3bbd 100644 --- a/packages/fluorflow_generator/test/builder/dialog_builder_test.dart +++ b/packages/fluorflow_generator/test/builder/dialog_builder_test.dart @@ -3,7 +3,6 @@ import 'package:build_test/build_test.dart'; import 'package:fluorflow/annotations.dart'; import 'package:fluorflow_generator/src/builder/dialog_builder.dart'; import 'package:recase/recase.dart'; -import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; void main() { @@ -1388,7 +1387,6 @@ extension Dialogs on _i1.DialogService { import 'b.dart'; @DialogConfig( - routeBuilder: RouteBuilder.custom, pageRouteBuilder: CustomBuilder, ) class MyDialog extends FluorFlowSimpleDialog { @@ -1433,37 +1431,7 @@ extension Dialogs on _i1.DialogService { }, reader: await PackageAssetReader.currentIsolate())); - test( - 'should throw when custom page is requested, but no page builder is provided.', - () async { - try { - await testBuilder( - DialogBuilder(BuilderOptions.empty), - { - 'a|lib/a.dart': ''' - import 'package:fluorflow/annotations.dart'; - import 'package:fluorflow/fluorflow.dart'; - import 'package:flutter/material.dart'; - - @DialogConfig( - routeBuilder: RouteBuilder.custom, - ) - class MyDialog extends FluorFlowSimpleDialog { - const MyDialog({super.key, required this.completer}); - } - - class CustomBuilder extends PageRouteBuilder {} - ''' - }, - reader: await PackageAssetReader.currentIsolate()); - fail('Should have thrown'); - } catch (e) { - expect(e, isA()); - } - }); - for (final (transition, resultBuilder) in RouteBuilder.values - .where((t) => t != RouteBuilder.custom) .map((t) => (t, '${t.name.pascalCase}PageRouteBuilder'))) { test( 'should use correct page route builder ' diff --git a/packages/fluorflow_generator/test/builder/router_builder_test.dart b/packages/fluorflow_generator/test/builder/router_builder_test.dart index e72f582..7d46cd3 100644 --- a/packages/fluorflow_generator/test/builder/router_builder_test.dart +++ b/packages/fluorflow_generator/test/builder/router_builder_test.dart @@ -3,7 +3,6 @@ import 'package:build_test/build_test.dart'; import 'package:fluorflow/annotations.dart'; import 'package:fluorflow_generator/src/builder/router_builder.dart'; import 'package:recase/recase.dart'; -import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; void main() { @@ -313,7 +312,7 @@ extension RouteNavigation on _i2.NavigationService { import 'package:flutter/material.dart'; import 'b.dart'; - @Routable(routeBuilder: RouteBuilder.custom, pageRouteBuilder: CustomBuilder) + @Routable(pageRouteBuilder: CustomBuilder) class View extends StatelessWidget {} ''', 'a|lib/b.dart': ''' @@ -368,36 +367,7 @@ extension RouteNavigation on _i4.NavigationService { }, reader: await PackageAssetReader.currentIsolate())); - test( - 'should throw when custom page is requested, but no page builder is provided.', - () async { - try { - await testBuilder( - RouterBuilder(BuilderOptions.empty), - { - 'a|lib/a.dart': ''' - import 'package:fluorflow/annotations.dart'; - import 'package:flutter/material.dart'; - import 'b.dart'; - - @Routable(routeBuilder: RouteBuilder.custom) - class View extends StatelessWidget {} - ''', - 'a|lib/b.dart': ''' - import 'package:flutter/material.dart'; - - class CustomBuilder extends PageRouteBuilder {} - ''' - }, - reader: await PackageAssetReader.currentIsolate()); - fail('Should have thrown'); - } catch (e) { - expect(e, isA()); - } - }); - for (final (transition, resultBuilder) in RouteBuilder.values - .where((t) => t != RouteBuilder.custom) .map((t) => (t, '${t.name.pascalCase}PageRouteBuilder'))) { test( 'should use correct page route builder '