diff --git a/examples/cookbook/architecture/command/analysis_options.yaml b/examples/cookbook/architecture/command/analysis_options.yaml new file mode 100644 index 0000000000..eee60e0f5a --- /dev/null +++ b/examples/cookbook/architecture/command/analysis_options.yaml @@ -0,0 +1,5 @@ +# Take our settings from the example_utils analysis_options.yaml file. +# If necessary for a particular example, this file can also include +# overrides for individual lints. + +include: package:example_utils/analysis.yaml diff --git a/examples/cookbook/architecture/command/lib/command.dart b/examples/cookbook/architecture/command/lib/command.dart new file mode 100644 index 0000000000..0ada1f163a --- /dev/null +++ b/examples/cookbook/architecture/command/lib/command.dart @@ -0,0 +1,104 @@ +// Copyright 2024 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'result.dart'; + +/// Defines a command action that returns a [Result] of type [T]. +/// Used by [Command0] for actions without arguments. +typedef CommandAction0 = Future> Function(); + +/// Defines a command action that returns a [Result] of type [T]. +/// Takes an argument of type [A]. +/// Used by [Command1] for actions with one argument. +typedef CommandAction1 = Future> Function(A); + +/// Facilitates interaction with a view model. +/// +/// Encapsulates an action, +/// exposes its running and error states, +/// and ensures that it can't be launched again until it finishes. +/// +/// Use [Command0] for actions without arguments. +/// Use [Command1] for actions with one argument. +/// +/// Actions must return a [Result] of type [T]. +/// +/// Consume the action result by listening to changes, +/// then call to [clearResult] when the state is consumed. +abstract class Command extends ChangeNotifier { + bool _running = false; + + /// Whether the action is running. + bool get running => _running; + + Result? _result; + + /// Whether the action completed with an error. + bool get error => _result is Error; + + /// Whether the action completed successfully. + bool get completed => _result is Ok; + + /// The result of the most recent action. + /// + /// Returns `null` if the action is running or completed with an error. + Result? get result => _result; + + /// Clears the most recent action's result. + void clearResult() { + _result = null; + notifyListeners(); + } + + /// Execute the provided [action], notifying listeners and + /// setting the running and result states as necessary. + Future _execute(CommandAction0 action) async { + // Ensure the action can't launch multiple times. + // e.g. avoid multiple taps on button + if (_running) return; + + // Notify listeners. + // e.g. button shows loading state + _running = true; + _result = null; + notifyListeners(); + + try { + _result = await action(); + } finally { + _running = false; + notifyListeners(); + } + } +} + +/// A [Command] that accepts no arguments. +final class Command0 extends Command { + /// Creates a [Command0] with the provided [CommandAction0]. + Command0(this._action); + + final CommandAction0 _action; + + /// Executes the action. + Future execute() async { + await _execute(() => _action()); + } +} + +/// A [Command] that accepts one argument. +final class Command1 extends Command { + /// Creates a [Command1] with the provided [CommandAction1]. + Command1(this._action); + + final CommandAction1 _action; + + /// Executes the action with the specified [argument]. + Future execute(A argument) async { + await _execute(() => _action(argument)); + } +} diff --git a/examples/cookbook/architecture/command/lib/extended_command.dart b/examples/cookbook/architecture/command/lib/extended_command.dart new file mode 100644 index 0000000000..f1efa8c41f --- /dev/null +++ b/examples/cookbook/architecture/command/lib/extended_command.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +// #docregion HomeViewModel +class HomeViewModel extends ChangeNotifier { + HomeViewModel() { + load = Command0(_load)..execute(); + edit = Command1(_edit); + } + + User? get user => null; + + // Command0 accepts 0 arguments + late final Command0 load; + + // Command1 accepts 1 argument + late final Command1 edit; + + Future _load() async { + // load user + } + + Future _edit(String name) async { + // edit user + } +} +// #enddocregion HomeViewModel + +class User {} + +class Command0 { + Command0(Future Function() any); + + void execute() {} +} + +class Command1 { + Command1(Future Function(T) any); +} diff --git a/examples/cookbook/architecture/command/lib/main.dart b/examples/cookbook/architecture/command/lib/main.dart new file mode 100644 index 0000000000..0aa78a4790 --- /dev/null +++ b/examples/cookbook/architecture/command/lib/main.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; + +class MainApp extends StatefulWidget { + const MainApp({ + super.key, + required this.viewModel, + }); + + final HomeViewModel viewModel; + + @override + State createState() => _MainAppState(); +} + +class _MainAppState extends State { + // #docregion addListener + @override + void initState() { + super.initState(); + widget.viewModel.addListener(_onViewModelChanged); + } + + @override + void dispose() { + widget.viewModel.removeListener(_onViewModelChanged); + super.dispose(); + } + // #enddocregion addListener + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + // #docregion CommandListenable + // #docregion ListenableBuilder + body: ListenableBuilder( + listenable: widget.viewModel.load, + builder: (context, child) { + if (widget.viewModel.load.running) { + return const Center( + child: CircularProgressIndicator(), + ); + } + // #enddocregion CommandListenable + + if (widget.viewModel.load.error != null) { + return Center( + child: Text('Error: ${widget.viewModel.load.error}'), + ); + } + + return child!; + }, + child: ListenableBuilder( + listenable: widget.viewModel, + builder: (context, _) { + // #enddocregion ListenableBuilder + if (widget.viewModel.user == null) { + return const Center( + child: Text('No user'), + ); + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Name: ${widget.viewModel.user!.name}'), + Text('Email: ${widget.viewModel.user!.email}'), + ], + ), + ); + // #docregion ListenableBuilder + }, + ), + // #docregion CommandListenable + ), + // #enddocregion ListenableBuilder + // #enddocregion CommandListenable + ), + ); + } + + // #docregion _onViewModelChanged + void _onViewModelChanged() { + if (widget.viewModel.load.error != null) { + widget.viewModel.load.clear(); + // Show Snackbar + } + } + // #enddocregion _onViewModelChanged +} + +class User { + User({required this.name, required this.email}); + + final String name; + final String email; +} + +// #docregion HomeViewModel +class HomeViewModel extends ChangeNotifier { + // #docregion ViewModelInit + HomeViewModel() { + load = Command(_load)..execute(); + } + // #enddocregion ViewModelInit + + User? get user => null; + + late final Command load; + + Future _load() async { + // load user + } +} +// #enddocregion HomeViewModel + +// #docregion HomeViewModel2 +class HomeViewModel2 extends ChangeNotifier { + HomeViewModel2() { + load = Command(_load)..execute(); + delete = Command(_delete); + } + + User? get user => null; + + late final Command load; + + late final Command delete; + + Future _load() async { + // load user + } + + Future _delete() async { + // delete user + } +} +// #enddocregion HomeViewModel2 + +// #docregion Command +class Command extends ChangeNotifier { + Command(this._action); + + bool _running = false; + bool get running => _running; + + Exception? _error; + Exception? get error => _error; + + bool _completed = false; + bool get completed => _completed; + + final Future Function() _action; + + Future execute() async { + if (_running) { + return; + } + + _running = true; + _completed = false; + _error = null; + notifyListeners(); + + try { + await _action(); + _completed = true; + } on Exception catch (error) { + _error = error; + } finally { + _running = false; + notifyListeners(); + } + } + + void clear() { + _running = false; + _error = null; + _completed = false; + } +} +// #enddocregion Command diff --git a/examples/cookbook/architecture/command/lib/no_command.dart b/examples/cookbook/architecture/command/lib/no_command.dart new file mode 100644 index 0000000000..70f2475c3a --- /dev/null +++ b/examples/cookbook/architecture/command/lib/no_command.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; + +class MainApp extends StatefulWidget { + const MainApp({ + super.key, + required this.viewModel, + }); + + final HomeViewModel2 viewModel; + + @override + State createState() => _MainAppState(); +} + +class _MainAppState extends State { + // #docregion addListener + @override + void initState() { + super.initState(); + widget.viewModel.addListener(_onViewModelChanged); + } + + @override + void dispose() { + widget.viewModel.removeListener(_onViewModelChanged); + super.dispose(); + } + // #enddocregion addListener + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + // #docregion ListenableBuilder + body: ListenableBuilder( + listenable: widget.viewModel, + builder: (context, _) { + if (widget.viewModel.running) { + return const Center( + child: CircularProgressIndicator(), + ); + } + // #enddocregion ListenableBuilder + + if (widget.viewModel.error != null) { + return Center( + child: Text('Error: ${widget.viewModel.error}'), + ); + } + + if (widget.viewModel.user == null) { + return const Center( + child: Text('No user'), + ); + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Name: ${widget.viewModel.user!.name}'), + Text('Email: ${widget.viewModel.user!.email}'), + ], + ), + ); + // #docregion ListenableBuilder + }, + ), + // #enddocregion ListenableBuilder + ), + ); + } + + // #docregion _onViewModelChanged + void _onViewModelChanged() { + if (widget.viewModel.error != null) { + widget.viewModel.clearError(); + // Show Snackbar + } + } + // #enddocregion _onViewModelChanged +} + +class User { + User({required this.name, required this.email}); + + final String name; + final String email; +} + +// #docregion HomeViewModel2 +// #docregion getUser +// #docregion load1 +// #docregion UiState1 +class HomeViewModel2 extends ChangeNotifier { + // #enddocregion HomeViewModel2 + + User? get user => null; + // #enddocregion getUser + // #enddocregion load1 + + bool get running => false; + + Exception? get error => null; + + // #docregion load1 + void load() { + // load user + } + // #enddocregion load1 + // #enddocregion UiState1 + + // #docregion load2 + void load2() { + if (running) { + return; + } + // load user + } + + // #enddocregion load2 + void clearError() {} + // #docregion UiState1 + // #docregion load1 + // #docregion HomeViewModel2 + // #docregion getUser +} +// #enddocregion HomeViewModel2 +// #enddocregion getUser +// #enddocregion load1 +// #enddocregion UiState1 + +// #docregion HomeViewModel3 +class HomeViewModel3 extends ChangeNotifier { + User? get user => null; + + bool get runningLoad => false; + + Exception? get errorLoad => null; + + bool get runningEdit => false; + + Exception? get errorEdit => null; + + void load() { + // load user + } + + void edit(String name) { + // edit user + } +} +// #enddocregion HomeViewModel3 diff --git a/examples/cookbook/architecture/command/lib/result.dart b/examples/cookbook/architecture/command/lib/result.dart new file mode 100644 index 0000000000..497c458f66 --- /dev/null +++ b/examples/cookbook/architecture/command/lib/result.dart @@ -0,0 +1,56 @@ +// Copyright 2024 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Utility class that simplifies handling errors. +/// +/// Return a [Result] from a function to indicate success or failure. +/// +/// A [Result] is either an [Ok] with a value of type [T] +/// or an [Error] with an [Exception]. +/// +/// Use [Result.ok] to create a successful result with a value of type [T]. +/// Use [Result.error] to create an error result with an [Exception]. +/// +/// Evaluate the result using a switch statement: +/// ```dart +/// switch (result) { +/// case Ok(): { +/// print(result.value); +/// } +/// case Error(): { +/// print(result.error); +/// } +/// } +/// ``` +sealed class Result { + const Result(); + + /// Creates a successful [Result], completed with the specified [value]. + const factory Result.ok(T value) = Ok._; + + /// Creates an error [Result], completed with the specified [error]. + const factory Result.error(Exception error) = Error._; +} + +/// A successful [Result] with a returned [value]. +final class Ok extends Result { + const Ok._(this.value); + + /// The returned value of this result. + final T value; + + @override + String toString() => 'Result<$T>.ok($value)'; +} + +/// An error [Result] with a resulting [error]. +final class Error extends Result { + const Error._(this.error); + + /// The resulting error of this result. + final Exception error; + + @override + String toString() => 'Result<$T>.error($error)'; +} diff --git a/examples/cookbook/architecture/command/lib/simple_command.dart b/examples/cookbook/architecture/command/lib/simple_command.dart new file mode 100644 index 0000000000..89519e93e2 --- /dev/null +++ b/examples/cookbook/architecture/command/lib/simple_command.dart @@ -0,0 +1,43 @@ +// ignore_for_file: unused_field, prefer_final_fields + +import 'package:flutter/material.dart'; + +class User {} + +// #docregion Command +class Command extends ChangeNotifier { + Command(this._action); + + bool get running => false; + + Exception? get error => null; + + bool get completed => false; + + void Function() _action; + + void execute() { + // run _action + } + + void clear() { + // clear state + } +} +// #enddocregion Command + +// #docregion ViewModel +class HomeViewModel extends ChangeNotifier { + HomeViewModel() { + load = Command(_load)..execute(); + } + + User? get user => null; + + late final Command load; + + void _load() { + // load user + } +} +// #enddocregion ViewModel diff --git a/examples/cookbook/architecture/command/pubspec.yaml b/examples/cookbook/architecture/command/pubspec.yaml new file mode 100644 index 0000000000..02ace9942d --- /dev/null +++ b/examples/cookbook/architecture/command/pubspec.yaml @@ -0,0 +1,16 @@ +name: command +description: Example for command cookbook recipe + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + example_utils: + path: ../../../example_utils + +flutter: + uses-material-design: true diff --git a/examples/cookbook/architecture/result/analysis_options.yaml b/examples/cookbook/architecture/result/analysis_options.yaml new file mode 100644 index 0000000000..eee60e0f5a --- /dev/null +++ b/examples/cookbook/architecture/result/analysis_options.yaml @@ -0,0 +1,5 @@ +# Take our settings from the example_utils analysis_options.yaml file. +# If necessary for a particular example, this file can also include +# overrides for individual lints. + +include: package:example_utils/analysis.yaml diff --git a/examples/cookbook/architecture/result/lib/main.dart b/examples/cookbook/architecture/result/lib/main.dart new file mode 100644 index 0000000000..76a0d53409 --- /dev/null +++ b/examples/cookbook/architecture/result/lib/main.dart @@ -0,0 +1,117 @@ +// ignore_for_file: unused_field + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'result.dart'; + +class UserProfile { + final String name; + final String email; + + UserProfile(this.name, this.email); + + static UserProfile fromJson(_) { + return UserProfile('John Doe', 'email@example.com'); + } +} + +// #docregion UserProfileViewModel +class UserProfileViewModel extends ChangeNotifier { + // #enddocregion UserProfileViewModel + final UserProfileRepository userProfileRepository = UserProfileRepository( + ApiClientService(), + DatabaseService(), + ); + // #docregion UserProfileViewModel + + UserProfile? userProfile; + + Exception? error; + + Future load() async { + final result = await userProfileRepository.getUserProfile(); + switch (result) { + case Ok(): + userProfile = result.value; + case Error(): + error = result.error; + } + notifyListeners(); + } +} +// #enddocregion UserProfileViewModel + +class UserProfileRepository { + final ApiClientService _apiClientService; + final DatabaseService _databaseService; + + UserProfileRepository( + this._apiClientService, + this._databaseService, + ); + + // #docregion getUserProfile1 + Future> getUserProfile1() async { + return await _apiClientService.getUserProfile(); + } + // #enddocregion getUserProfile1 + + // #docregion getUserProfile + Future> getUserProfile() async { + final apiResult = await _apiClientService.getUserProfile(); + if (apiResult is Ok) { + return apiResult; + } + + final databaseResult = await _databaseService.createTemporaryUser(); + if (databaseResult is Ok) { + return databaseResult; + } + + return Result.error(Exception('Failed to get user profile')); + } + // #enddocregion getUserProfile +} + +// #docregion ApiClientService1 +// #docregion ApiClientService2 +class ApiClientService { + // #enddocregion ApiClientService1 + // #enddocregion ApiClientService2 + final HttpClient client = HttpClient(); + final String _host = 'api.example.com'; + final int _port = 443; + // #docregion ApiClientService1 + // #docregion ApiClientService2 + + Future> getUserProfile() async { + // #enddocregion ApiClientService1 + try { + final request = await client.get(_host, _port, '/user'); + final response = await request.close(); + if (response.statusCode == 200) { + final stringData = await response.transform(utf8.decoder).join(); + return Result.ok(UserProfile.fromJson(jsonDecode(stringData))); + } else { + return const Result.error(HttpException('Invalid response')); + } + } on Exception catch (exception) { + return Result.error(exception); + } finally { + client.close(); + } + // #docregion ApiClientService1 + } +} +// #enddocregion ApiClientService1 +// #enddocregion ApiClientService2 + +class DatabaseService { + Future> createTemporaryUser() async { + await Future.delayed(const Duration(seconds: 2)); + return Result.ok(UserProfile('John Doe', 'john@example.com')); + } +} diff --git a/examples/cookbook/architecture/result/lib/no_result.dart b/examples/cookbook/architecture/result/lib/no_result.dart new file mode 100644 index 0000000000..be66f197ea --- /dev/null +++ b/examples/cookbook/architecture/result/lib/no_result.dart @@ -0,0 +1,114 @@ +// ignore_for_file: unused_catch_clause, unused_field + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +class UserProfile { + final String name; + final String email; + + UserProfile(this.name, this.email); + + static UserProfile fromJson(_) { + return UserProfile('John Doe', 'email@example.com'); + } +} + +// #docregion ApiClientService +class ApiClientService { + // #enddocregion ApiClientService + final HttpClient client = HttpClient(); + final String _host = 'api.example.com'; + final int _port = 443; + // #docregion ApiClientService + + Future getUserProfile() async { + try { + final request = await client.get(_host, _port, '/user'); + final response = await request.close(); + if (response.statusCode == 200) { + final stringData = await response.transform(utf8.decoder).join(); + return UserProfile.fromJson(jsonDecode(stringData)); + } else { + throw const HttpException('Invalid response'); + } + } finally { + client.close(); + } + } +} +// #enddocregion ApiClientService + +// #docregion UserProfileRepository +class UserProfileRepository { + // #enddocregion UserProfileRepository + final ApiClientService _apiClientService = ApiClientService(); + // #docregion UserProfileRepository + + Future getUserProfile() async { + return await _apiClientService.getUserProfile(); + } +} +// #enddocregion UserProfileRepository + +// #docregion UserProfileRepository2 +class UserProfileRepository2 { + // #enddocregion UserProfileRepository2 + final ApiClientService _apiClientService = ApiClientService(); + final DatabaseService _databaseService = DatabaseService(); + // #docregion UserProfileRepository2 + + Future getUserProfile() async { + try { + return await _apiClientService.getUserProfile(); + } catch (e) { + try { + return await _databaseService.createTemporaryUser(); + } catch (e) { + throw Exception('Failed to get user profile'); + } + } + } +} +// #enddocregion UserProfileRepository2 + +// #docregion UserProfileViewModel +class UserProfileViewModel extends ChangeNotifier { + // #enddocregion UserProfileViewModel + final UserProfileRepository userProfileRepository = UserProfileRepository(); + UserProfile? _userProfile; + // #docregion UserProfileViewModel + + Future load() async { + try { + _userProfile = await userProfileRepository.getUserProfile(); + notifyListeners(); + } on Exception catch (exception) { + // handle exception + } + } +} +// #enddocregion UserProfileViewModel + +// #docregion UserProfileViewModelNoTryCatch +class UserProfileViewModelNoTryCatch extends ChangeNotifier { + // #enddocregion UserProfileViewModelNoTryCatch + final UserProfileRepository userProfileRepository = UserProfileRepository(); + UserProfile? _userProfile; + // #docregion UserProfileViewModelNoTryCatch + + Future load() async { + _userProfile = await userProfileRepository.getUserProfile(); + notifyListeners(); + } +} +// #enddocregion UserProfileViewModelNoTryCatch + +class DatabaseService { + Future createTemporaryUser() async { + await Future.delayed(const Duration(seconds: 2)); + return UserProfile('John Doe', 'john@example.com'); + } +} diff --git a/examples/cookbook/architecture/result/lib/result.dart b/examples/cookbook/architecture/result/lib/result.dart new file mode 100644 index 0000000000..c405dc0936 --- /dev/null +++ b/examples/cookbook/architecture/result/lib/result.dart @@ -0,0 +1,58 @@ +// Copyright 2024 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// #docregion Result +/// Utility class that simplifies handling errors. +/// +/// Return a [Result] from a function to indicate success or failure. +/// +/// A [Result] is either an [Ok] with a value of type [T] +/// or an [Error] with an [Exception]. +/// +/// Use [Result.ok] to create a successful result with a value of type [T]. +/// Use [Result.error] to create an error result with an [Exception]. +/// +/// Evaluate the result using a switch statement: +/// ```dart +/// switch (result) { +/// case Ok(): { +/// print(result.value); +/// } +/// case Error(): { +/// print(result.error); +/// } +/// } +/// ``` +sealed class Result { + const Result(); + + /// Creates a successful [Result], completed with the specified [value]. + const factory Result.ok(T value) = Ok._; + + /// Creates an error [Result], completed with the specified [error]. + const factory Result.error(Exception error) = Error._; +} + +/// A successful [Result] with a returned [value]. +final class Ok extends Result { + const Ok._(this.value); + + /// The returned value of this result. + final T value; + + @override + String toString() => 'Result<$T>.ok($value)'; +} + +/// An error [Result] with a resulting [error]. +final class Error extends Result { + const Error._(this.error); + + /// The resulting error of this result. + final Exception error; + + @override + String toString() => 'Result<$T>.error($error)'; +} +// #enddocregion Result diff --git a/examples/cookbook/architecture/result/lib/simple_result.dart b/examples/cookbook/architecture/result/lib/simple_result.dart new file mode 100644 index 0000000000..5454e9eb9b --- /dev/null +++ b/examples/cookbook/architecture/result/lib/simple_result.dart @@ -0,0 +1,34 @@ +/// Utility class that simplifies handling errors. +/// +/// Return a [Result] from a function to indicate success or failure. +/// +/// A [Result] is either an [Ok] with a value of type [T] +/// or an [Error] with an [Exception]. +/// +/// Use [Result.ok] to create a successful result with a value of type [T]. +/// Use [Result.error] to create an error result with an [Exception]. +sealed class Result { + const Result(); + + /// Creates an instance of Result containing a value + factory Result.ok(T value) => Ok(value); + + /// Create an instance of Result containing an error + factory Result.error(Exception error) => Error(error); +} + +/// Subclass of Result for values +final class Ok extends Result { + const Ok(this.value); + + /// Returned value in result + final T value; +} + +/// Subclass of Result for errors +final class Error extends Result { + const Error(this.error); + + /// Returned error in result + final Exception error; +} diff --git a/examples/cookbook/architecture/result/pubspec.yaml b/examples/cookbook/architecture/result/pubspec.yaml new file mode 100644 index 0000000000..e4563a19af --- /dev/null +++ b/examples/cookbook/architecture/result/pubspec.yaml @@ -0,0 +1,16 @@ +name: result +description: Example for result cookbook recipe + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + example_utils: + path: ../../../example_utils + +flutter: + uses-material-design: true diff --git a/src/_data/sidenav.yml b/src/_data/sidenav.yml index 25f4c13aca..505236fe4d 100644 --- a/src/_data/sidenav.yml +++ b/src/_data/sidenav.yml @@ -447,6 +447,10 @@ children: - title: Optimistic state permalink: /cookbook/architecture/optimistic-state + - title: Command + permalink: /cookbook/architecture/command + - title: Result + permalink: /cookbook/architecture/result - title: "Persistent storage architecture: Key-value data" permalink: /cookbook/architecture/key-value-data - title: "Persistent storage architecture: SQL" diff --git a/src/content/cookbook/architecture/command.md b/src/content/cookbook/architecture/command.md new file mode 100644 index 0000000000..4269112aee --- /dev/null +++ b/src/content/cookbook/architecture/command.md @@ -0,0 +1,634 @@ +--- +title: Command pattern +description: >- + Learn how to improve your view models with commands. +js: + - defer: true + url: /assets/js/inject_dartpad.js +--- + + + +[Model-View-ViewModel (MVVM)][] is a design pattern +that separates a feature of an application into three parts: +the model, the view model and the view. +Views and view models make up the UI layer of an application. +Repositories and services represent the data layer of an application, +or the model layer of MVVM. + +A command is a class that wraps a method +and helps to handle the different states of that method, +such as running, complete, and error. + +[View models][] can use commands to handle interaction and run actions. +As well, they can be used to display different UI states, +like loading indicators when an action is running, +or an error dialog when an action failed. + +View models can become very complex +as an application grows +and features become bigger. +Commands can help to simplify view models +and reuse code. + +In this guide, you will learn +how to use the command pattern +to improve your view models. + +## Challenges when implementing view models + +View model classes in Flutter are typically implemented +by extending the [`ChangeNotifier`][] class. +This allows view models to call `notifyListeners()` to refresh views +when data is updated. + + +```dart +class HomeViewModel extends ChangeNotifier { + // ··· +} +``` + +View models contain a representation of the UI state, +including the data being displayed. +For example, this `HomeViewModel` exposes the `User` instance to the view. + + +```dart +class HomeViewModel extends ChangeNotifier { + + User? get user => // ... + // ··· +} +``` + +View models also contain actions typically triggered by the view; +for example, a `load` action in charge of loading the `user`. + + +```dart +class HomeViewModel extends ChangeNotifier { + + User? get user => // ... + // ··· + void load() { + // load user + } + // ··· +} +``` + +### UI state in view models + +A view model also contains UI state besides data, such as +whether the view is running or has experienced an error. +This allows the app to tell the user if the action has completed successfully. + + +```dart +class HomeViewModel extends ChangeNotifier { + + User? get user => // ... + + bool get running => // ... + + Exception? get error => // ... + + void load() { + // load user + } + // ··· +} +``` + +You can use the running state to display a progress indicator in the view: + + +```dart +ListenableBuilder( + listenable: widget.viewModel, + builder: (context, _) { + if (widget.viewModel.running) { + return const Center( + child: CircularProgressIndicator(), + ); + } + // ··· + }, +) +``` + +Or use the running state to avoid executing the action multiple times: + + +```dart +void load() { + if (running) { + return; + } + // load user +} + +``` + +Managing the state of an action can get complicated +if the view model contains multiple actions. +For example, adding an `edit()` action to the `HomeViewModel` +can lead the following outcome: + + +```dart +class HomeViewModel extends ChangeNotifier { + User? get user => // ... + + bool get runningLoad => // ... + + Exception? get errorLoad => // ... + + bool get runningEdit => // ... + + Exception? get errorEdit => // ... + + void load() { + // load user + } + + void edit(String name) { + // edit user + } +} +``` + +Sharing the running state +between the `load()` and `edit()` actions might not always work, +because you might want to show a different UI component +when the `load()` action runs than when the `edit()` action runs, +and you'll have the same problem with the `error` state. + +### Triggering UI actions from view models + +View model classes can run into problems when +executing UI actions and the view model's state changes. + +For example, you might want to show a `SnackBar` when an error occurs, +or navigate to a different screen when an action completes. +To implement this, listen for changes in the view model, +and perform the action depending on the state. + +In the view: + + +```dart +@override +void initState() { + super.initState(); + widget.viewModel.addListener(_onViewModelChanged); +} + +@override +void dispose() { + widget.viewModel.removeListener(_onViewModelChanged); + super.dispose(); +} +``` + + +```dart +void _onViewModelChanged() { + if (widget.viewModel.error != null) { + // Show Snackbar + } +} +``` + +You need to clear the error state each time you execute this action, +otherwise this action happens each time `notifyListeners()` is called. + + +```dart +void _onViewModelChanged() { + if (widget.viewModel.error != null) { + widget.viewModel.clearError(); + // Show Snackbar + } +} +``` + +## Command pattern + +You might find yourself repeating the above code over and over, +implementing a different running state +for each action in every view model. +At that point, it makes sense to extract this code +into a reusable pattern: a command. + +A command is a class that encapsulates a view model action, +and exposes the different states that an action can have. + + +```dart +class Command extends ChangeNotifier { + Command(this._action); + + bool get running => // ... + + Exception? get error => // ... + + bool get completed => // ... + + void Function() _action; + + void execute() { + // run _action + } + + void clear() { + // clear state + } +} +``` + +In the view model, +instead of defining an action directly with a method, +you create a command object: + + +```dart +class HomeViewModel extends ChangeNotifier { + HomeViewModel() { + load = Command(_load)..execute(); + } + + User? get user => // ... + + late final Command load; + + void _load() { + // load user + } +} +``` + +The previous `load()` method becomes `_load()`, +and instead the command `load` gets exposed to the View. +The previous `running` and `error` states can be removed, +as they are now part of the command. + +### Executing a command + +Instead of calling `viewModel.load()` to run the load action, +now you call `viewModel.load.execute()`. + +The `execute()` method can also be called from within the view model. +The following line of code runs the `load` command when the +view model is created. + + +```dart +HomeViewModel() { + load = Command(_load)..execute(); +} +``` + +The `execute()` method sets the running state to `true` +and resets the `error` and `completed` states. +When the action finishes, +the `running` state changes to `false` +and the `completed` state to `true`. + +If the `running` state is `true`, the command cannot begin executing again. +This prevents users from triggering a command multiple times by pressing a button rapidly. + +The command’s `execute()` method captures any thrown `Exceptions` +automatically and exposes them in the `error` state. + +The following code shows a sample `Command` class that +has been simplified for demo purposes. +You can see a full implementation at the end of this page. + + +```dart +class Command extends ChangeNotifier { + Command(this._action); + + bool _running = false; + bool get running => _running; + + Exception? _error; + Exception? get error => _error; + + bool _completed = false; + bool get completed => _completed; + + final Future Function() _action; + + Future execute() async { + if (_running) { + return; + } + + _running = true; + _completed = false; + _error = null; + notifyListeners(); + + try { + await _action(); + _completed = true; + } on Exception catch (error) { + _error = error; + } finally { + _running = false; + notifyListeners(); + } + } + + void clear() { + _running = false; + _error = null; + _completed = false; + } +} +``` + +### Listening to the command state + +The `Command` class extends from `ChangeNotifier`, +allowing Views to listen to its states. + +In the `ListenableBuilder`, +instead of passing the view model to `ListenableBuilder.listenable`, +pass the command: + + + +```dart +ListenableBuilder( + listenable: widget.viewModel.load, + builder: (context, child) { + if (widget.viewModel.load.running) { + return const Center( + child: CircularProgressIndicator(), + ); + } + // ··· +) +``` + +And listen to changes in the command state in order to run UI actions: + + +```dart +@override +void initState() { + super.initState(); + widget.viewModel.addListener(_onViewModelChanged); +} + +@override +void dispose() { + widget.viewModel.removeListener(_onViewModelChanged); + super.dispose(); +} +``` + + +```dart +void _onViewModelChanged() { + if (widget.viewModel.load.error != null) { + widget.viewModel.load.clear(); + // Show Snackbar + } +} +``` + +### Combining command and ViewModel + +You can stack multiple `ListenableBuilder` widgets to listen to `running` +and `error` states before showing the view model data. + + +```dart +body: ListenableBuilder( + listenable: widget.viewModel.load, + builder: (context, child) { + if (widget.viewModel.load.running) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (widget.viewModel.load.error != null) { + return Center( + child: Text('Error: ${widget.viewModel.load.error}'), + ); + } + + return child!; + }, + child: ListenableBuilder( + listenable: widget.viewModel, + builder: (context, _) { + // ··· + }, + ), +), +``` + +You can define multiple commands classes in a single view model, +simplifying its implementation +and minimizing the amount of repeated code. + + +```dart +class HomeViewModel2 extends ChangeNotifier { + HomeViewModel2() { + load = Command(_load)..execute(); + delete = Command(_delete); + } + + User? get user => // ... + + late final Command load; + + late final Command delete; + + Future _load() async { + // load user + } + + Future _delete() async { + // delete user + } +} +``` + +### Extending the command pattern + +The command pattern can be extended in multiple ways. +For example, to support a different number of arguments. + + +```dart +class HomeViewModel extends ChangeNotifier { + HomeViewModel() { + load = Command0(_load)..execute(); + edit = Command1(_edit); + } + + User? get user => // ... + + // Command0 accepts 0 arguments + late final Command0 load; + + // Command1 accepts 1 argument + late final Command1 edit; + + Future _load() async { + // load user + } + + Future _edit(String name) async { + // edit user + } +} +``` + +## Putting it all together + +In this guide, +you learned how to use the command design pattern +to improve the implementation of view models +when using the MVVM design pattern. + +Below, you can find the full `Command` class +as implemented in the [Compass App example][] +for the Flutter architecture guidelines. +It also uses the [`Result` class][] +to determine if the action completed successfuly or with an error. + +This implementation also includes two types of commands, +a `Command0`, for actions without parameters, +and a `Command1`, for actions that take one parameter. + +:::note +Check [pub.dev][] for other ready-to-use +implementations of the command pattern, +such as the [`flutter_command`][] package. +::: + + +```dart +// Copyright 2024 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'result.dart'; + +/// Defines a command action that returns a [Result] of type [T]. +/// Used by [Command0] for actions without arguments. +typedef CommandAction0 = Future> Function(); + +/// Defines a command action that returns a [Result] of type [T]. +/// Takes an argument of type [A]. +/// Used by [Command1] for actions with one argument. +typedef CommandAction1 = Future> Function(A); + +/// Facilitates interaction with a view model. +/// +/// Encapsulates an action, +/// exposes its running and error states, +/// and ensures that it can't be launched again until it finishes. +/// +/// Use [Command0] for actions without arguments. +/// Use [Command1] for actions with one argument. +/// +/// Actions must return a [Result] of type [T]. +/// +/// Consume the action result by listening to changes, +/// then call to [clearResult] when the state is consumed. +abstract class Command extends ChangeNotifier { + bool _running = false; + + /// Whether the action is running. + bool get running => _running; + + Result? _result; + + /// Whether the action completed with an error. + bool get error => _result is Error; + + /// Whether the action completed successfully. + bool get completed => _result is Ok; + + /// The result of the most recent action. + /// + /// Returns `null` if the action is running or completed with an error. + Result? get result => _result; + + /// Clears the most recent action's result. + void clearResult() { + _result = null; + notifyListeners(); + } + + /// Execute the provided [action], notifying listeners and + /// setting the running and result states as necessary. + Future _execute(CommandAction0 action) async { + // Ensure the action can't launch multiple times. + // e.g. avoid multiple taps on button + if (_running) return; + + // Notify listeners. + // e.g. button shows loading state + _running = true; + _result = null; + notifyListeners(); + + try { + _result = await action(); + } finally { + _running = false; + notifyListeners(); + } + } +} + +/// A [Command] that accepts no arguments. +final class Command0 extends Command { + /// Creates a [Command0] with the provided [CommandAction0]. + Command0(this._action); + + final CommandAction0 _action; + + /// Executes the action. + Future execute() async { + await _execute(() => _action()); + } +} + +/// A [Command] that accepts one argument. +final class Command1 extends Command { + /// Creates a [Command1] with the provided [CommandAction1]. + Command1(this._action); + + final CommandAction1 _action; + + /// Executes the action with the specified [argument]. + Future execute(A argument) async { + await _execute(() => _action(argument)); + } +} +``` + +[Compass App example]: {{site.repo.samples}}/tree/main/compass_app +[`Result` class]: /cookbook/architecture/result +[pub.dev]: {{site.pub}} +[`flutter_command`]: {{site.pub-pkg}}/flutter_command +[`ChangeNotifier`]: /get-started/fundamentals/state-management +[Model-View-ViewModel (MVVM)]: /app-architecture/guide#view-models +[View models]: /app-architecture/guide#view-models diff --git a/src/content/cookbook/architecture/result.md b/src/content/cookbook/architecture/result.md new file mode 100644 index 0000000000..11f8d9b824 --- /dev/null +++ b/src/content/cookbook/architecture/result.md @@ -0,0 +1,470 @@ +--- +title: Result class +description: >- + Learn to handle errors and return values with a result class. +js: + - defer: true + url: /assets/js/inject_dartpad.js +--- + + + +Dart provides a built-in error handling mechanism +with the ability to throw and catch exceptions. + +As mentioned in the [Error handling documentation][], +Dart's exceptions are unhandled exceptions. +This means that methods that throw exceptions don’t need to declare them, +and calling methods aren't required to catch them either. + +This can lead to situations where exceptions are not handled properly. +In large projects, +developers might forget to catch exceptions, +and the different application layers and components +could throw exceptions that aren’t documented. +This can lead to errors and crashes. + +In this guide, +you will learn about this limitation +and how to mitigate it using the _result_ pattern. + +## Error flow in Flutter applications + +Applications following the [Flutter architecture guidelines][] +are usually composed of view models, +repositories, and services, among other parts. +When a function in one of these components fails, +it should communicate the error to the calling component. + +Typically, that's done with exceptions. +For example, +an API client service failing to communicate with the remote server +might throw an HTTP Error Exception. +The calling component, +for example a Repository, +would have to either capture this exception +or ignore it and let the calling view model handle it. + +This can be observed in the following example. Consider these classes: + +- A service,`ApiClientService`, performs API calls to a remote service. +- A repository, `UserProfileRepository`, + provides the `UserProfile` provided by the `ApiClientService`. +- A view model, `UserProfileViewModel`, uses the `UserProfileRepository`. + +The `ApiClientService` contains a method, `getUserProfile`, +that throws exceptions in certain situations: + +- The method throws an `HttpException` if the response code isn’t 200. +- The JSON parsing method throws an exception + if the response isn't formatted correctly. +- The HTTP client might throw an exception due to networking issues. + +The following code tests for a variety of possible exceptions: + + +```dart +class ApiClientService { + // ··· + + Future getUserProfile() async { + try { + final request = await client.get(_host, _port, '/user'); + final response = await request.close(); + if (response.statusCode == 200) { + final stringData = await response.transform(utf8.decoder).join(); + return UserProfile.fromJson(jsonDecode(stringData)); + } else { + throw const HttpException('Invalid response'); + } + } finally { + client.close(); + } + } +} +``` + +The `UserProfileRepository` doesn’t need to handle +the exceptions from the `ApiClientService`. +In this example, it just returns the value from the API Client. + + +```dart +class UserProfileRepository { + // ··· + + Future getUserProfile() async { + return await _apiClientService.getUserProfile(); + } +} +``` + +Finally, the `UserProfileViewModel` +should capture all exceptions and handle the errors. + +This can be done by wrapping +the call to the `UserProfileRepository` with a try-catch: + + +```dart +class UserProfileViewModel extends ChangeNotifier { + // ··· + + Future load() async { + try { + _userProfile = await userProfileRepository.getUserProfile(); + notifyListeners(); + } on Exception catch (exception) { + // handle exception + } + } +} +``` + +In reality, a developer might forget to properly capture exceptions and +end up with the following code. +It compiles and runs, but crashes if +one of the exceptions mentioned previously occurs: + + +```dart +class UserProfileViewModel extends ChangeNotifier { + // ··· + + Future load() async { + _userProfile = await userProfileRepository.getUserProfile(); + notifyListeners(); + } +} +``` + +You can attempt to solve this by documenting the `ApiClientService`, +warning about the possible exceptions it might throw. +However, since the view model doesn’t use the service directly, +other developers working in the codebase might miss this information. + +## Using the result pattern + +An alternative to throwing exceptions +is to wrap the function output in a `Result` object. + +When the function runs successfully, +the `Result` contains the returned value. +However, if the function does not complete successfully, +the `Result` object contains the error. + +A `Result` is a [`sealed`][] class +that can either subclass `Ok` or the `Error` class. +Return the successful value with the subclass `Ok`, +and the captured error with the subclass `Error`. + +The following code shows a sample `Result` class that +has been simplified for demo purposes. +A full implementation is at the end of this page. + + +```dart +/// Utility class that simplifies handling errors. +/// +/// Return a [Result] from a function to indicate success or failure. +/// +/// A [Result] is either an [Ok] with a value of type [T] +/// or an [Error] with an [Exception]. +/// +/// Use [Result.ok] to create a successful result with a value of type [T]. +/// Use [Result.error] to create an error result with an [Exception]. +sealed class Result { + const Result(); + + /// Creates an instance of Result containing a value + factory Result.ok(T value) => Ok(value); + + /// Create an instance of Result containing an error + factory Result.error(Exception error) => Error(error); +} + +/// Subclass of Result for values +final class Ok extends Result { + const Ok(this.value); + + /// Returned value in result + final T value; +} + +/// Subclass of Result for errors +final class Error extends Result { + const Error(this.error); + + /// Returned error in result + final Exception error; +} +``` + +In this example, +the `Result` class uses a generic type `T` to represent any return value, +which can be a primitive Dart type like `String` or an `int` or a custom class like `UserProfile`. + +### Creating a `Result` object + +For functions using the `Result` class to return values, +instead of a value, +the function returns a `Result` object containing the value. + +For example, in the `ApiClientService`, +`getUserProfile` is changed to return a `Result`: + + +```dart +class ApiClientService { + // ··· + + Future> getUserProfile() async { + // ··· + } +} +``` + +Instead of returning the `UserProfile` directly, +it returns a `Result` object containing a `UserProfile`. + +To facilitate using the `Result` class, +it contains two named constructors, `Result.ok` and `Result.error`. +Use them to construct the `Result` depending on desired output. +As well, capture any exceptions thrown by the code +and wrap them into the `Result` object. + +For example, here the `getUserProfile()` method +has been changed to use the `Result` class: + + +```dart +class ApiClientService { + // ··· + + Future> getUserProfile() async { + try { + final request = await client.get(_host, _port, '/user'); + final response = await request.close(); + if (response.statusCode == 200) { + final stringData = await response.transform(utf8.decoder).join(); + return Result.ok(UserProfile.fromJson(jsonDecode(stringData))); + } else { + return const Result.error(HttpException('Invalid response')); + } + } on Exception catch (exception) { + return Result.error(exception); + } finally { + client.close(); + } + } +} +``` + +The original return statement was replaced +with a statement that returns the value using `Result.ok`. +The `throw HttpException()` +was replaced with a statement that returns `Result.error(HttpException())`, +wrapping the error into a `Result`. +As well, the method is wrapped with a `try-catch` block +to capture any exceptions thrown by the Http client +or the JSON parser into a `Result.error`. + +The repository class also needs to be modified, +and instead of returning a `UserProfile` directly, +now it returns a `Result`. + + +```dart +Future> getUserProfile() async { + return await _apiClientService.getUserProfile(); +} +``` + +### Unwrapping the Result object + +Now the view model doesn't receive the `UserProfile` directly, +but instead it receives a `Result` containing a `UserProfile`. + +This forces the developer implementing the view model +to unwrap the `Result` to obtain the `UserProfile`, +and avoids having uncaught exceptions. + + +```dart +class UserProfileViewModel extends ChangeNotifier { + // ··· + + UserProfile? userProfile; + + Exception? error; + + Future load() async { + final result = await userProfileRepository.getUserProfile(); + switch (result) { + case Ok(): + userProfile = result.value; + case Error(): + error = result.error; + } + notifyListeners(); + } +} +``` + +The `Result` class is implemented using a `sealed` class, +meaning it can only be of type `Ok` or `Error`. +This allows the code to evaluate the result with a +[switch result or expression][]. + +In the `Ok` case, +obtain the value using the `value` property. + +In the `Error` case, +obtain the error object using the `error` property. + +## Improving control flow + +Wrapping code in a `try-catch` block ensures that +thrown exceptions are caught and not propagated to other parts of the code. + +Consider the following code. + + +```dart +class UserProfileRepository { + // ··· + + Future getUserProfile() async { + try { + return await _apiClientService.getUserProfile(); + } catch (e) { + try { + return await _databaseService.createTemporaryUser(); + } catch (e) { + throw Exception('Failed to get user profile'); + } + } + } +} +``` + +In this method, the `UserProfileRepository` +attempts to obtain the `UserProfile` +using the `ApiClientService`. +If it fails, it tries to create a temporary user in a `DatabaseService`. + +Because either service method can fail, +the code must catch the exceptions in both cases. + +This can be improved using the `Result` pattern: + + + +```dart +Future> getUserProfile() async { + final apiResult = await _apiClientService.getUserProfile(); + if (apiResult is Ok) { + return apiResult; + } + + final databaseResult = await _databaseService.createTemporaryUser(); + if (databaseResult is Ok) { + return databaseResult; + } + + return Result.error(Exception('Failed to get user profile')); +} +``` + +In this code, if the `Result` object is an `Ok` instance, +then the function returns that object; +otherwise, it returns `Result.Error`. + +## Putting it all together + +In this guide, you have learned +how to use a `Result` class to return result values. + +The key takeaways are: + +- `Result` classes force the calling method to check for errors, + reducing the amount of bugs caused by uncaught exceptions. +- `Result` classes help improve control flow compared to try-catch blocks. +- `Result` classes are `sealed` and can only return `Ok` or `Error` instances, + allowing the code to unwrap them with a switch statement. + +Below you can find the full `Result` class +as implemented in the [Compass App example][] +for the [Flutter architecture guidelines][]. + +:::note +Check [pub.dev][] for different ready-to-use +implementations of the `Result` class, +such as the [`result_dart`][], [`result_type`][], and [`multiple_result`][] packages. +::: + + +```dart +/// Utility class that simplifies handling errors. +/// +/// Return a [Result] from a function to indicate success or failure. +/// +/// A [Result] is either an [Ok] with a value of type [T] +/// or an [Error] with an [Exception]. +/// +/// Use [Result.ok] to create a successful result with a value of type [T]. +/// Use [Result.error] to create an error result with an [Exception]. +/// +/// Evaluate the result using a switch statement: +/// ```dart +/// switch (result) { +/// case Ok(): { +/// print(result.value); +/// } +/// case Error(): { +/// print(result.error); +/// } +/// } +/// ``` +sealed class Result { + const Result(); + + /// Creates a successful [Result], completed with the specified [value]. + const factory Result.ok(T value) = Ok._; + + /// Creates an error [Result], completed with the specified [error]. + const factory Result.error(Exception error) = Error._; +} + +/// A successful [Result] with a returned [value]. +final class Ok extends Result { + const Ok._(this.value); + + /// The returned value of this result. + final T value; + + @override + String toString() => 'Result<$T>.ok($value)'; +} + +/// An error [Result] with a resulting [error]. +final class Error extends Result { + const Error._(this.error); + + /// The resulting error of this result. + final Exception error; + + @override + String toString() => 'Result<$T>.error($error)'; +} +``` + +[Error handling documentation]: {{site.dart-site}}/language/error-handling +[Flutter architecture guidelines]: /app-architecture +[Compass App example]: {{site.repo.samples}}/tree/main/compass_app +[pub.dev]: {{site.pub}} +[`result_dart`]: {{site.pub-pkg}}/result_dart +[`result_type`]: {{site.pub-pkg}}/result_type +[`multiple_result`]: {{site.pub-pkg}}/multiple_result +[`sealed`]: {{site.dart-site}}/language/class-modifiers#sealed +[switch result or expression]: {{site.dart-site}}/language/branches#switch-statements