Skip to content

Commit

Permalink
feat: simplify data view model (#5)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: This removes the complex initialization
logic of the `DataViewModel<T>`. Instead of using the
`initializeData` method, now the constructor of the view model
requires some form of initial data. This requires developers to
explicitely define nullable types and allows the `data` field to be
initialized in all cases. To migrate, remove all `initializeData` calls
and provide the constructor with some default data. It is still possible
to load data asynchronously, by overwriting the `initialize` method and
fetching data there. One is responsible to call `super.initialize` in
error cases.

BREAKING CHANGE: This simplifies the routable and dialog config by
removing the `RouteBuilder.custom` variant. Basically, to use a custom
page route builder, just use the provided property (`pageRouteBuilder`)
and do not set the `routeBuilder` property. If the page route builder is
provided,
the route builder property is ignored.
  • Loading branch information
buehler authored Mar 18, 2024
1 parent 2d0613d commit fad3176
Show file tree
Hide file tree
Showing 12 changed files with 144 additions and 128 deletions.
5 changes: 5 additions & 0 deletions packages/fluorflow/example/lib/views/detail/detail_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ final class DetailView extends FluorFlowView<DetailViewModel> {
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'),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<int> {
final _navService = locator<NavigationService>();
final _sheets = locator<BottomSheetService>();

DetailViewModel() : super(0);

void showBottomSheet() =>
_sheets.showGreetingBottomSheet(callback: () {}, onElement: (_) {});

void back() => _navService.back();

void addOne() => data += 1;
}
2 changes: 1 addition & 1 deletion packages/fluorflow/lib/src/annotations/dialog_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
1 change: 1 addition & 0 deletions packages/fluorflow/lib/src/annotations/routable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 0 additions & 3 deletions packages/fluorflow/lib/src/navigation/route_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,4 @@ enum RouteBuilder {

/// Zoom in transition.
zoomIn,

/// Custom transition.
custom,
}
1 change: 1 addition & 0 deletions packages/fluorflow/lib/src/viewmodels/base_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
107 changes: 55 additions & 52 deletions packages/fluorflow/lib/src/viewmodels/data_viewmodel.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -30,73 +32,74 @@ import 'base_viewmodel.dart';
/// }
///
/// final class HomeViewModel extends DataViewModel<HomeState> {
/// @override
/// FutureOr<HomeState> initializeData() => const LoadingState();
/// HomeViewModel() : super(const LoadingState());
///
/// void loadData() async {
/// await Future.delayed(const Duration(seconds: 2));
/// data = const LoadedState('Hello, World!');
/// }
/// }
/// ```
///
/// 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<HomeState> {
/// HomeViewModel() : super(const LoadingState());
///
/// @override
/// FutureOr<void> initialize() async {
/// await Future.delayed(const Duration(seconds: 2));
/// data = const LoadedState('Hello, World!');
/// await super.initialize();
/// }
/// }
/// ```
abstract base class DataViewModel<TData> extends BaseViewModel {
late final ValueNotifier<TData> _data;
final ValueNotifier<TData> _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<TData> get dataNotifier =>
initialized ? _data : (throw StateError('ViewModel is not initialized.'));
ValueNotifier<TData> 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<TData> 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<void> initialize() async {
try {
_data = ValueNotifier(await initializeData());
if (notifyOnDataChange) {
_data.addListener(notifyListeners);
}
await super.initialize();
} catch (e) {
error = e;
onDataInitializeError(e);
}
}
}
71 changes: 71 additions & 0 deletions packages/fluorflow/test/viewmodels/data_viewmode_test.dart
Original file line number Diff line number Diff line change
@@ -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<String> {
_NormalViewModel() : super('Hello, World!');

@override
FutureOr<void> initialize() {
data = 'Hello, brave new World!';
return super.initialize();
}
}

final class _ErrorViewModel extends DataViewModel<String> {
_ErrorViewModel() : super('Hello, World!');

@override
FutureOr<void> initialize() async {
try {
throw StateError('Error');
} catch (e) {
error = e;
} finally {
await super.initialize();
}
}
}

void main() {
group('DataViewModel<TData>', () {
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<StateError>());
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 0 additions & 32 deletions packages/fluorflow_generator/test/builder/dialog_builder_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -1388,7 +1387,6 @@ extension Dialogs on _i1.DialogService {
import 'b.dart';
@DialogConfig(
routeBuilder: RouteBuilder.custom,
pageRouteBuilder: CustomBuilder,
)
class MyDialog extends FluorFlowSimpleDialog {
Expand Down Expand Up @@ -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<InvalidGenerationSourceError>());
}
});

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 '
Expand Down
Loading

0 comments on commit fad3176

Please sign in to comment.