diff --git a/docs/_snippets/recipes_flutter_navigation/navigation2/book_bloc.dart.md b/docs/_snippets/recipes_flutter_navigation/navigation2/book_bloc.dart.md new file mode 100644 index 00000000000..3fd382e8e36 --- /dev/null +++ b/docs/_snippets/recipes_flutter_navigation/navigation2/book_bloc.dart.md @@ -0,0 +1,12 @@ +```dart +class BookBloc extends Bloc { + BookBloc() : super(BookState()) { + on((event, emit) { + emit(state.copyWith(selectedBook: () => event.book)); + }); + on((event, emit) { + emit(state.copyWith(selectedBook: () => null)); + }); + } +} +``` \ No newline at end of file diff --git a/docs/_snippets/recipes_flutter_navigation/navigation2/book_event.dart.md b/docs/_snippets/recipes_flutter_navigation/navigation2/book_event.dart.md new file mode 100644 index 00000000000..fd8ee03686a --- /dev/null +++ b/docs/_snippets/recipes_flutter_navigation/navigation2/book_event.dart.md @@ -0,0 +1,21 @@ +```dart +abstract class BookEvent extends Equatable { + const BookEvent(); + + @override + List get props => []; +} + +class BookSelected extends BookEvent { + const BookSelected({required this.book}); + + final Book book; + + @override + List get props => [book]; +} + +class BookDeselected extends BookEvent { + const BookDeselected(); +} +``` diff --git a/docs/_snippets/recipes_flutter_navigation/navigation2/book_state.dart.md b/docs/_snippets/recipes_flutter_navigation/navigation2/book_state.dart.md new file mode 100644 index 00000000000..238c94b5a8a --- /dev/null +++ b/docs/_snippets/recipes_flutter_navigation/navigation2/book_state.dart.md @@ -0,0 +1,37 @@ +```dart +class Book extends Equatable { + const Book(this.title, this.author); + + final String title; + final String author; + + @override + List get props => [title, author]; +} + +const defaultBooks = [ + Book('Left Hand of Darkness', 'Ursula K. Le Guin'), + Book('Too Like the Lightning', 'Ada Palmer'), + Book('Kindred', 'Octavia E. Butler'), +]; + +class BookState extends Equatable { + const BookState({this.selectedBook, this.books = defaultBooks}); + + final Book? selectedBook; + final List books; + + @override + List get props => [selectedBook, books]; + + BookState copyWith({ + ValueGetter? selectedBook, + ValueGetter>? books, + }) { + return BookState( + selectedBook: selectedBook != null ? selectedBook() : this.selectedBook, + books: books != null ? books() : this.books, + ); + } +} +``` \ No newline at end of file diff --git a/docs/_snippets/recipes_flutter_navigation/navigation2/main.dart.md b/docs/_snippets/recipes_flutter_navigation/navigation2/main.dart.md new file mode 100644 index 00000000000..7aa31b77d22 --- /dev/null +++ b/docs/_snippets/recipes_flutter_navigation/navigation2/main.dart.md @@ -0,0 +1,100 @@ +```dart +void main() { + runApp( + BlocProvider( + create: (_) => BookBloc(), + child: BooksApp(), + ), + ); +} + +class BooksApp extends StatelessWidget { + const BooksApp({Key? key}) : super(key: key); + + List onGeneratePages(BookState state, List pages) { + final selectedBook = state.selectedBook; + return [ + BooksListPage.page(books: state.books), + if (selectedBook != null) BookDetailsPage.page(book: selectedBook) + ]; + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Books App', + home: FlowBuilder( + state: context.watch().state, + onGeneratePages: onGeneratePages, + ), + ); + } +} + +class BooksListPage extends StatelessWidget { + const BooksListPage({Key? key, required this.books}) : super(key: key); + + static Page page({required List books}) { + return MaterialPage( + child: BooksListPage(books: books), + ); + } + + final List books; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Books')), + body: ListView( + children: [ + for (final book in books) + ListTile( + title: Text(book.title), + subtitle: Text(book.author), + onTap: () { + context.read().add(BookSelected(book: book)); + }, + ) + ], + ), + ); + } +} + +class BookDetailsPage extends StatelessWidget { + const BookDetailsPage({Key? key, required this.book}); + + static Page page({required Book book}) { + return MaterialPage( + child: BookDetailsPage(book: book), + ); + } + + final Book book; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return WillPopScope( + onWillPop: () async { + context.read().add(BookDeselected()); + return true; + }, + child: Scaffold( + appBar: AppBar(title: const Text('Details')), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(book.title, style: theme.textTheme.headline6), + Text(book.author, style: theme.textTheme.subtitle1), + ], + ), + ), + ), + ); + } +} +``` diff --git a/docs/flutterweathertutorial.md b/docs/flutterweathertutorial.md index ce24878e59a..2f7e4107594 100644 --- a/docs/flutterweathertutorial.md +++ b/docs/flutterweathertutorial.md @@ -130,7 +130,7 @@ In a later step, we will also use the [http](https://pub.dev/packages/http) pack Let's add these dependencies to the `pubspec.yaml`. -[pubspec.yaml](https://raw.githubusercontent.com/felangel/bloc/master/examples/flutter_weather/packages/weather_repository/pubspec.yaml ':include') +[pubspec.yaml](https://raw.githubusercontent.com/felangel/bloc/master/examples/flutter_weather/packages/meta_weather_api/pubspec.yaml ':include') ?> **Note**: Remember to run `flutter pub get` after adding the dependencies. diff --git a/docs/recipesflutternavigation.md b/docs/recipesflutternavigation.md index 25cfd65f7a0..710a947112b 100644 --- a/docs/recipesflutternavigation.md +++ b/docs/recipesflutternavigation.md @@ -1,6 +1,6 @@ # Recipes: Navigation -> In this recipe, we're going to take a look at how to use `BlocBuilder` and/or `BlocListener` to do navigation. We're going to explore two approaches: Direct Navigation and Route Navigation. +> In this recipe, we're going to take a look at how to use `BlocBuilder` and/or `BlocListener` to do navigation. We're going to explore three approaches: Direct Navigation, Route Navigation, and Declarative Navigation using Navigator 2.0. ## Direct Navigation @@ -66,3 +66,40 @@ Let's take a look at how to route to a different page based on the state of `MyB !> For the sake of this example, we are adding an event just for navigation. In a real application, you should not create explicit navigation events. If there is no "business logic" necessary in order to trigger navigation, you should always directly navigate in response to user input (in the `onPressed` callback, etc...). Only navigate in response to state changes if some "business logic" is required in order to determine where to navigate. The full source for this recipe can be found [here](https://gist.github.com/felangel/6bcd4be10c046ceb33eecfeb380135dd). + +## Navigation 2.0 + +> In this example, we're going to take a look at how to use the Navigator 2.0 pages API to handle routing in response to state changes in a bloc. + +?> Note: We're going to use [package:flow_builder](https://pub.dev/packages/flow_builder) to make working with the Navigator 2.0 API simpler. + +### Bloc + +For the sake of showcasing Navigator's advantages, we will build a slightly more complex example. +Let's build `BookBloc` which will take `BookEvents` and convert them into `BookStates`. + +#### BookEvent + +`BookEvent` will respond to two events: selecting a book, and deselecting a book. + +[book_event.dart](_snippets/recipes_flutter_navigation/navigation2/book_event.dart.md ':include') + +#### BookState + +`BookState` will contain the list of books and an optional selected book if the user taps a book. + +[book_state.dart](_snippets/recipes_flutter_navigation/navigation2/book_state.dart.md ':include') + +#### BookBloc + +`BookBloc` will handle responding to each `BookEvent` and will emit the appropriate `BookState` in response: + +[book_bloc.dart](_snippets/recipes_flutter_navigation/navigation2/book_bloc.dart.md ':include') + +### UI Layer + +Now let's hook up the bloc to our UI using `FlowBuilder`! + +[main.dart](_snippets/recipes_flutter_navigation/navigation2/main.dart.md ':include') + +The full source for this recipe can be found [here](https://gist.github.com/felangel/bd3cf504a10c0763a32f7a94e2649369). \ No newline at end of file diff --git a/examples/flutter_complex_list/test/complex_list/cubit/complex_list_cubit_test.dart b/examples/flutter_complex_list/test/complex_list/cubit/complex_list_cubit_test.dart index 6e04f26b5f3..20e64d96528 100644 --- a/examples/flutter_complex_list/test/complex_list/cubit/complex_list_cubit_test.dart +++ b/examples/flutter_complex_list/test/complex_list/cubit/complex_list_cubit_test.dart @@ -30,10 +30,10 @@ void main() { group('fetchList', () { blocTest( 'emits ComplexListState.success after fetching list', - build: () { + setUp: () { when(repository.fetchItems).thenAnswer((_) async => mockItems); - return ComplexListCubit(repository: repository); }, + build: () => ComplexListCubit(repository: repository), act: (cubit) => cubit.fetchList(), expect: () => [ const ComplexListState.success(mockItems), @@ -43,10 +43,10 @@ void main() { blocTest( 'emits ComplexListState.failure after failing to fetch list', - build: () { + setUp: () { when(repository.fetchItems).thenThrow(Exception('Error')); - return ComplexListCubit(repository: repository); }, + build: () => ComplexListCubit(repository: repository), act: (cubit) => cubit.fetchList(), expect: () => [ const ComplexListState.failure(), @@ -58,10 +58,10 @@ void main() { group('deleteItem', () { blocTest( 'emits corrects states when deleting an item', - build: () { + setUp: () { when(() => repository.deleteItem('2')).thenAnswer((_) async => null); - return ComplexListCubit(repository: repository); }, + build: () => ComplexListCubit(repository: repository), seed: () => const ComplexListState.success(mockItems), act: (cubit) => cubit.deleteItem('2'), expect: () => [ diff --git a/examples/flutter_dynamic_form/test/new_car/bloc/new_car_bloc_test.dart b/examples/flutter_dynamic_form/test/new_car/bloc/new_car_bloc_test.dart index 50e2d777361..45bb306ba2d 100644 --- a/examples/flutter_dynamic_form/test/new_car/bloc/new_car_bloc_test.dart +++ b/examples/flutter_dynamic_form/test/new_car/bloc/new_car_bloc_test.dart @@ -30,10 +30,10 @@ void main() { blocTest( 'emits brands loading in progress and brands load success', - build: () { + setUp: () { when(newCarRepository.fetchBrands).thenAnswer((_) async => mockBrands); - return NewCarBloc(newCarRepository: newCarRepository); }, + build: () => NewCarBloc(newCarRepository: newCarRepository), act: (bloc) => bloc.add(const NewCarFormLoaded()), expect: () => [ const NewCarState.brandsLoadInProgress(), @@ -44,12 +44,12 @@ void main() { blocTest( 'emits models loading in progress and models load success', - build: () { - when(() => newCarRepository.fetchModels(brand: mockBrand)).thenAnswer( - (_) async => mockModels, - ); - return NewCarBloc(newCarRepository: newCarRepository); + setUp: () { + when( + () => newCarRepository.fetchModels(brand: mockBrand), + ).thenAnswer((_) async => mockModels); }, + build: () => NewCarBloc(newCarRepository: newCarRepository), act: (bloc) => bloc.add(NewCarBrandChanged(brand: mockBrand)), expect: () => [ NewCarState.modelsLoadInProgress(brands: [], brand: mockBrand), @@ -66,12 +66,12 @@ void main() { blocTest( 'emits years loading in progress and year load success', - build: () { + setUp: () { when( () => newCarRepository.fetchYears(brand: null, model: mockModel), ).thenAnswer((_) async => mockYears); - return NewCarBloc(newCarRepository: newCarRepository); }, + build: () => NewCarBloc(newCarRepository: newCarRepository), act: (bloc) => bloc.add(NewCarModelChanged(model: mockModel)), expect: () => [ NewCarState.yearsLoadInProgress( @@ -104,18 +104,18 @@ void main() { blocTest( 'emits correct states when complete flow is executed', - build: () { - when(newCarRepository.fetchBrands).thenAnswer( - (_) => Future.value(mockBrands), - ); + setUp: () { + when( + newCarRepository.fetchBrands, + ).thenAnswer((_) => Future.value(mockBrands)); when( () => newCarRepository.fetchModels(brand: mockBrand), ).thenAnswer((_) => Future.value(mockModels)); when( () => newCarRepository.fetchYears(brand: mockBrand, model: mockModel), ).thenAnswer((_) => Future.value(mockYears)); - return NewCarBloc(newCarRepository: newCarRepository); }, + build: () => NewCarBloc(newCarRepository: newCarRepository), act: (bloc) => bloc ..add(const NewCarFormLoaded()) ..add(NewCarBrandChanged(brand: mockBrand)) diff --git a/examples/flutter_firebase_login/test/app/bloc/app_bloc_test.dart b/examples/flutter_firebase_login/test/app/bloc/app_bloc_test.dart index 16feb79a103..f43bd6eb5ad 100644 --- a/examples/flutter_firebase_login/test/app/bloc/app_bloc_test.dart +++ b/examples/flutter_firebase_login/test/app/bloc/app_bloc_test.dart @@ -35,25 +35,29 @@ void main() { group('UserChanged', () { blocTest( 'emits authenticated when user is not empty', - build: () { + setUp: () { when(() => user.isNotEmpty).thenReturn(true); when(() => authenticationRepository.user).thenAnswer( (_) => Stream.value(user), ); - return AppBloc(authenticationRepository: authenticationRepository); }, + build: () => AppBloc( + authenticationRepository: authenticationRepository, + ), seed: () => AppState.unauthenticated(), expect: () => [AppState.authenticated(user)], ); blocTest( 'emits unauthenticated when user is empty', - build: () { + setUp: () { when(() => authenticationRepository.user).thenAnswer( (_) => Stream.value(User.empty), ); - return AppBloc(authenticationRepository: authenticationRepository); }, + build: () => AppBloc( + authenticationRepository: authenticationRepository, + ), expect: () => [AppState.unauthenticated()], ); }); @@ -61,9 +65,9 @@ void main() { group('LogoutRequested', () { blocTest( 'invokes logOut', - build: () { - return AppBloc(authenticationRepository: authenticationRepository); - }, + build: () => AppBloc( + authenticationRepository: authenticationRepository, + ), act: (bloc) => bloc.add(AppLogoutRequested()), verify: (_) { verify(() => authenticationRepository.logOut()).called(1); diff --git a/examples/flutter_firebase_login/test/login/cubit/login_cubit_test.dart b/examples/flutter_firebase_login/test/login/cubit/login_cubit_test.dart index 80c405dbf05..e5789e5bf3d 100644 --- a/examples/flutter_firebase_login/test/login/cubit/login_cubit_test.dart +++ b/examples/flutter_firebase_login/test/login/cubit/login_cubit_test.dart @@ -150,15 +150,15 @@ void main() { blocTest( 'emits [submissionInProgress, submissionFailure] ' 'when logInWithEmailAndPassword fails', - build: () { + setUp: () { when( () => authenticationRepository.logInWithEmailAndPassword( email: any(named: 'email'), password: any(named: 'password'), ), ).thenThrow(Exception('oops')); - return LoginCubit(authenticationRepository); }, + build: () => LoginCubit(authenticationRepository), seed: () => LoginState( status: FormzStatus.valid, email: validEmail, @@ -204,12 +204,12 @@ void main() { blocTest( 'emits [submissionInProgress, submissionFailure] ' 'when logInWithGoogle fails', - build: () { + setUp: () { when( () => authenticationRepository.logInWithGoogle(), ).thenThrow(Exception('oops')); - return LoginCubit(authenticationRepository); }, + build: () => LoginCubit(authenticationRepository), act: (cubit) => cubit.logInWithGoogle(), expect: () => const [ LoginState(status: FormzStatus.submissionInProgress), diff --git a/examples/flutter_firebase_login/test/sign_up/cubit/sign_up_cubit_test.dart b/examples/flutter_firebase_login/test/sign_up/cubit/sign_up_cubit_test.dart index 6c683a2de5d..9b11bde0657 100644 --- a/examples/flutter_firebase_login/test/sign_up/cubit/sign_up_cubit_test.dart +++ b/examples/flutter_firebase_login/test/sign_up/cubit/sign_up_cubit_test.dart @@ -145,8 +145,9 @@ void main() { blocTest( 'emits [invalid] when email/password/confirmedPassword are invalid', build: () => SignUpCubit(authenticationRepository), - act: (cubit) => - cubit.confirmedPasswordChanged(invalidConfirmedPasswordString), + act: (cubit) { + cubit.confirmedPasswordChanged(invalidConfirmedPasswordString); + }, expect: () => const [ SignUpState( confirmedPassword: invalidConfirmedPassword, @@ -176,9 +177,7 @@ void main() { 'emits [valid] when passwordChanged is called first and then ' 'confirmedPasswordChanged is called', build: () => SignUpCubit(authenticationRepository), - seed: () => SignUpState( - email: validEmail, - ), + seed: () => SignUpState(email: validEmail), act: (cubit) => cubit ..passwordChanged(validPasswordString) ..confirmedPasswordChanged(validConfirmedPasswordString), @@ -259,15 +258,15 @@ void main() { blocTest( 'emits [submissionInProgress, submissionFailure] ' 'when signUp fails', - build: () { + setUp: () { when( () => authenticationRepository.signUp( email: any(named: 'email'), password: any(named: 'password'), ), ).thenThrow(Exception('oops')); - return SignUpCubit(authenticationRepository); }, + build: () => SignUpCubit(authenticationRepository), seed: () => SignUpState( status: FormzStatus.valid, email: validEmail, diff --git a/examples/flutter_login/test/authentication/authentication_bloc_test.dart b/examples/flutter_login/test/authentication/authentication_bloc_test.dart index 39476510832..0a4e3cc69a0 100644 --- a/examples/flutter_login/test/authentication/authentication_bloc_test.dart +++ b/examples/flutter_login/test/authentication/authentication_bloc_test.dart @@ -34,15 +34,15 @@ void main() { blocTest( 'emits [unauthenticated] when status is unauthenticated', - build: () { + setUp: () { when(() => authenticationRepository.status).thenAnswer( (_) => Stream.value(AuthenticationStatus.unauthenticated), ); - return AuthenticationBloc( - authenticationRepository: authenticationRepository, - userRepository: userRepository, - ); }, + build: () => AuthenticationBloc( + authenticationRepository: authenticationRepository, + userRepository: userRepository, + ), expect: () => const [ AuthenticationState.unauthenticated(), ], @@ -50,16 +50,16 @@ void main() { blocTest( 'emits [authenticated] when status is authenticated', - build: () { + setUp: () { when(() => authenticationRepository.status).thenAnswer( (_) => Stream.value(AuthenticationStatus.authenticated), ); when(() => userRepository.getUser()).thenAnswer((_) async => user); - return AuthenticationBloc( - authenticationRepository: authenticationRepository, - userRepository: userRepository, - ); }, + build: () => AuthenticationBloc( + authenticationRepository: authenticationRepository, + userRepository: userRepository, + ), expect: () => const [ AuthenticationState.authenticated(user), ], @@ -69,16 +69,16 @@ void main() { group('AuthenticationStatusChanged', () { blocTest( 'emits [authenticated] when status is authenticated', - build: () { + setUp: () { when(() => authenticationRepository.status).thenAnswer( (_) => Stream.value(AuthenticationStatus.authenticated), ); when(() => userRepository.getUser()).thenAnswer((_) async => user); - return AuthenticationBloc( - authenticationRepository: authenticationRepository, - userRepository: userRepository, - ); }, + build: () => AuthenticationBloc( + authenticationRepository: authenticationRepository, + userRepository: userRepository, + ), act: (bloc) => bloc.add( const AuthenticationStatusChanged(AuthenticationStatus.authenticated), ), @@ -89,15 +89,15 @@ void main() { blocTest( 'emits [unauthenticated] when status is unauthenticated', - build: () { + setUp: () { when(() => authenticationRepository.status).thenAnswer( (_) => Stream.value(AuthenticationStatus.unauthenticated), ); - return AuthenticationBloc( - authenticationRepository: authenticationRepository, - userRepository: userRepository, - ); }, + build: () => AuthenticationBloc( + authenticationRepository: authenticationRepository, + userRepository: userRepository, + ), act: (bloc) => bloc.add( const AuthenticationStatusChanged(AuthenticationStatus.unauthenticated), ), @@ -108,13 +108,13 @@ void main() { blocTest( 'emits [unauthenticated] when status is authenticated but getUser fails', - build: () { + setUp: () { when(() => userRepository.getUser()).thenThrow(Exception('oops')); - return AuthenticationBloc( - authenticationRepository: authenticationRepository, - userRepository: userRepository, - ); }, + build: () => AuthenticationBloc( + authenticationRepository: authenticationRepository, + userRepository: userRepository, + ), act: (bloc) => bloc.add( const AuthenticationStatusChanged(AuthenticationStatus.authenticated), ), @@ -126,31 +126,32 @@ void main() { blocTest( 'emits [unauthenticated] when status is authenticated ' 'but getUser returns null', - build: () { + setUp: () { when(() => userRepository.getUser()).thenAnswer((_) async => null); - return AuthenticationBloc( - authenticationRepository: authenticationRepository, - userRepository: userRepository, - ); }, + build: () => AuthenticationBloc( + authenticationRepository: authenticationRepository, + userRepository: userRepository, + ), act: (bloc) => bloc.add( const AuthenticationStatusChanged(AuthenticationStatus.authenticated), ), - expect: () => - const [AuthenticationState.unauthenticated()], + expect: () => const [ + AuthenticationState.unauthenticated(), + ], ); blocTest( 'emits [unknown] when status is unknown', - build: () { + setUp: () { when(() => authenticationRepository.status).thenAnswer( (_) => Stream.value(AuthenticationStatus.unknown), ); - return AuthenticationBloc( - authenticationRepository: authenticationRepository, - userRepository: userRepository, - ); }, + build: () => AuthenticationBloc( + authenticationRepository: authenticationRepository, + userRepository: userRepository, + ), act: (bloc) => bloc.add( const AuthenticationStatusChanged(AuthenticationStatus.unknown), ), @@ -164,12 +165,10 @@ void main() { blocTest( 'calls logOut on authenticationRepository ' 'when AuthenticationLogoutRequested is added', - build: () { - return AuthenticationBloc( - authenticationRepository: authenticationRepository, - userRepository: userRepository, - ); - }, + build: () => AuthenticationBloc( + authenticationRepository: authenticationRepository, + userRepository: userRepository, + ), act: (bloc) => bloc.add(AuthenticationLogoutRequested()), verify: (_) { verify(() => authenticationRepository.logOut()).called(1); diff --git a/examples/flutter_login/test/login/bloc/login_bloc_test.dart b/examples/flutter_login/test/login/bloc/login_bloc_test.dart index 79ac1aa3734..a3559e8f286 100644 --- a/examples/flutter_login/test/login/bloc/login_bloc_test.dart +++ b/examples/flutter_login/test/login/bloc/login_bloc_test.dart @@ -9,16 +9,17 @@ class MockAuthenticationRepository extends Mock implements AuthenticationRepository {} void main() { - late LoginBloc loginBloc; late AuthenticationRepository authenticationRepository; setUp(() { authenticationRepository = MockAuthenticationRepository(); - loginBloc = LoginBloc(authenticationRepository: authenticationRepository); }); group('LoginBloc', () { test('initial state is LoginState', () { + final loginBloc = LoginBloc( + authenticationRepository: authenticationRepository, + ); expect(loginBloc.state, const LoginState()); }); @@ -26,15 +27,17 @@ void main() { blocTest( 'emits [submissionInProgress, submissionSuccess] ' 'when login succeeds', - build: () { + setUp: () { when( () => authenticationRepository.logIn( username: 'username', password: 'password', ), ).thenAnswer((_) => Future.value('user')); - return loginBloc; }, + build: () => LoginBloc( + authenticationRepository: authenticationRepository, + ), act: (bloc) { bloc ..add(const LoginUsernameChanged('username')) @@ -66,13 +69,17 @@ void main() { blocTest( 'emits [LoginInProgress, LoginFailure] when logIn fails', - build: () { - when(() => authenticationRepository.logIn( - username: 'username', - password: 'password', - )).thenThrow(Exception('oops')); - return loginBloc; + setUp: () { + when( + () => authenticationRepository.logIn( + username: 'username', + password: 'password', + ), + ).thenThrow(Exception('oops')); }, + build: () => LoginBloc( + authenticationRepository: authenticationRepository, + ), act: (bloc) { bloc ..add(const LoginUsernameChanged('username')) diff --git a/examples/flutter_shopping_cart/lib/cart/bloc/cart_bloc.dart b/examples/flutter_shopping_cart/lib/cart/bloc/cart_bloc.dart index 6abbac5ab68..a8fd0a409aa 100644 --- a/examples/flutter_shopping_cart/lib/cart/bloc/cart_bloc.dart +++ b/examples/flutter_shopping_cart/lib/cart/bloc/cart_bloc.dart @@ -12,6 +12,7 @@ class CartBloc extends Bloc { CartBloc({required this.shoppingRepository}) : super(CartLoading()) { on(_onStarted); on(_onItemAdded); + on(_onItemRemoved); } final ShoppingRepository shoppingRepository; @@ -32,7 +33,25 @@ class CartBloc extends Bloc { try { shoppingRepository.addItemToCart(event.item); emit(CartLoaded(cart: Cart(items: [...state.cart.items, event.item]))); - } on Exception { + } catch (_) { + emit(CartError()); + } + } + } + + void _onItemRemoved(CartItemRemoved event, Emitter emit) { + final state = this.state; + if (state is CartLoaded) { + try { + shoppingRepository.removeItemFromCart(event.item); + emit( + CartLoaded( + cart: Cart( + items: [...state.cart.items]..remove(event.item), + ), + ), + ); + } catch (_) { emit(CartError()); } } diff --git a/examples/flutter_shopping_cart/lib/cart/bloc/cart_event.dart b/examples/flutter_shopping_cart/lib/cart/bloc/cart_event.dart index 993acf599e3..a0d0bb10ecd 100644 --- a/examples/flutter_shopping_cart/lib/cart/bloc/cart_event.dart +++ b/examples/flutter_shopping_cart/lib/cart/bloc/cart_event.dart @@ -18,3 +18,12 @@ class CartItemAdded extends CartEvent { @override List get props => [item]; } + +class CartItemRemoved extends CartEvent { + const CartItemRemoved(this.item); + + final Item item; + + @override + List get props => [item]; +} diff --git a/examples/flutter_shopping_cart/lib/cart/view/cart_page.dart b/examples/flutter_shopping_cart/lib/cart/view/cart_page.dart index 49b1e75ca12..03fd6a12399 100644 --- a/examples/flutter_shopping_cart/lib/cart/view/cart_page.dart +++ b/examples/flutter_shopping_cart/lib/cart/view/cart_page.dart @@ -37,17 +37,25 @@ class CartList extends StatelessWidget { return const CircularProgressIndicator(); } if (state is CartLoaded) { - return ListView.builder( + return ListView.separated( itemCount: state.cart.items.length, - itemBuilder: (context, index) => Material( - child: ListTile( - leading: const Icon(Icons.done), - title: Text( - state.cart.items[index].name, - style: itemNameStyle, + separatorBuilder: (_, __) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final item = state.cart.items[index]; + return Material( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - ), - ), + clipBehavior: Clip.hardEdge, + child: ListTile( + leading: const Icon(Icons.done), + title: Text(item.name, style: itemNameStyle), + onLongPress: () { + context.read().add(CartItemRemoved(item)); + }, + ), + ); + }, ); } return const Text('Something went wrong!'); diff --git a/examples/flutter_shopping_cart/lib/shopping_repository.dart b/examples/flutter_shopping_cart/lib/shopping_repository.dart index 9473a71aace..146b14a8e1d 100644 --- a/examples/flutter_shopping_cart/lib/shopping_repository.dart +++ b/examples/flutter_shopping_cart/lib/shopping_repository.dart @@ -30,4 +30,6 @@ class ShoppingRepository { Future> loadCartItems() => Future.delayed(_delay, () => _items); void addItemToCart(Item item) => _items.add(item); + + void removeItemFromCart(Item item) => _items.remove(item); } diff --git a/examples/flutter_shopping_cart/test/cart/bloc/cart_bloc_test.dart b/examples/flutter_shopping_cart/test/cart/bloc/cart_bloc_test.dart index 00c38619d41..5e194d12105 100644 --- a/examples/flutter_shopping_cart/test/cart/bloc/cart_bloc_test.dart +++ b/examples/flutter_shopping_cart/test/cart/bloc/cart_bloc_test.dart @@ -16,6 +16,7 @@ void main() { ]; final mockItemToAdd = Item(4, 'item #4'); + final mockItemToRemove = Item(2, 'item #2'); late ShoppingRepository shoppingRepository; @@ -32,50 +33,46 @@ void main() { blocTest( 'emits [CartLoading, CartLoaded] when cart is loaded successfully', - build: () { + setUp: () { when(shoppingRepository.loadCartItems).thenAnswer((_) async => []); - return CartBloc(shoppingRepository: shoppingRepository); }, + build: () => CartBloc(shoppingRepository: shoppingRepository), act: (bloc) => bloc.add(CartStarted()), - expect: () => [ - CartLoading(), - const CartLoaded(), - ], + expect: () => [CartLoading(), const CartLoaded()], verify: (_) => verify(shoppingRepository.loadCartItems).called(1), ); blocTest( 'emits [CartLoading, CartError] when loading the cart throws an error', - build: () { + setUp: () { when(shoppingRepository.loadCartItems).thenThrow(Exception('Error')); - return CartBloc(shoppingRepository: shoppingRepository); }, + build: () => CartBloc(shoppingRepository: shoppingRepository), act: (bloc) => bloc..add(CartStarted()), - expect: () => [ - CartLoading(), - CartError(), - ], + expect: () => [CartLoading(), CartError()], verify: (_) => verify(shoppingRepository.loadCartItems).called(1), ); blocTest( 'emits [] when cart is not finished loading and item is added', - build: () { - when(() => shoppingRepository.addItemToCart(mockItemToAdd)) - .thenAnswer((_) async {}); - return CartBloc(shoppingRepository: shoppingRepository); + setUp: () { + when( + () => shoppingRepository.addItemToCart(mockItemToAdd), + ).thenAnswer((_) async {}); }, + build: () => CartBloc(shoppingRepository: shoppingRepository), act: (bloc) => bloc.add(CartItemAdded(mockItemToAdd)), expect: () => [], ); blocTest( 'emits [CartLoaded] when item is added successfully', - build: () { - when(() => shoppingRepository.addItemToCart(mockItemToAdd)) - .thenAnswer((_) async {}); - return CartBloc(shoppingRepository: shoppingRepository); + setUp: () { + when( + () => shoppingRepository.addItemToCart(mockItemToAdd), + ).thenAnswer((_) async {}); }, + build: () => CartBloc(shoppingRepository: shoppingRepository), seed: () => CartLoaded(cart: Cart(items: mockItems)), act: (bloc) => bloc.add(CartItemAdded(mockItemToAdd)), expect: () => [ @@ -88,19 +85,56 @@ void main() { blocTest( 'emits [CartError] when item is not added successfully', - build: () { - when(() => shoppingRepository.addItemToCart(mockItemToAdd)) - .thenThrow(Exception('Error')); - return CartBloc(shoppingRepository: shoppingRepository); + setUp: () { + when( + () => shoppingRepository.addItemToCart(mockItemToAdd), + ).thenThrow(Exception('Error')); }, + build: () => CartBloc(shoppingRepository: shoppingRepository), seed: () => CartLoaded(cart: Cart(items: mockItems)), act: (bloc) => bloc.add(CartItemAdded(mockItemToAdd)), + expect: () => [CartError()], + verify: (_) { + verify( + () => shoppingRepository.addItemToCart(mockItemToAdd), + ).called(1); + }, + ); + + blocTest( + 'emits [CartLoaded] when item is removed successfully', + setUp: () { + when( + () => shoppingRepository.removeItemFromCart(mockItemToRemove), + ).thenAnswer((_) async {}); + }, + build: () => CartBloc(shoppingRepository: shoppingRepository), + seed: () => CartLoaded(cart: Cart(items: mockItems)), + act: (bloc) => bloc.add(CartItemRemoved(mockItemToRemove)), expect: () => [ - CartError(), + CartLoaded(cart: Cart(items: [...mockItems]..remove(mockItemToRemove))) ], verify: (_) { verify( - () => shoppingRepository.addItemToCart(mockItemToAdd), + () => shoppingRepository.removeItemFromCart(mockItemToRemove), + ).called(1); + }, + ); + + blocTest( + 'emits [CartError] when item is not removed successfully', + setUp: () { + when( + () => shoppingRepository.removeItemFromCart(mockItemToRemove), + ).thenThrow(Exception('Error')); + }, + build: () => CartBloc(shoppingRepository: shoppingRepository), + seed: () => CartLoaded(cart: Cart(items: mockItems)), + act: (bloc) => bloc.add(CartItemRemoved(mockItemToRemove)), + expect: () => [CartError()], + verify: (_) { + verify( + () => shoppingRepository.removeItemFromCart(mockItemToRemove), ).called(1); }, ); diff --git a/examples/flutter_shopping_cart/test/cart/bloc/cart_event_test.dart b/examples/flutter_shopping_cart/test/cart/bloc/cart_event_test.dart index 7f0cfef3680..49e4a2b8d58 100644 --- a/examples/flutter_shopping_cart/test/cart/bloc/cart_event_test.dart +++ b/examples/flutter_shopping_cart/test/cart/bloc/cart_event_test.dart @@ -19,5 +19,12 @@ void main() { expect(CartItemAdded(item), CartItemAdded(item)); }); }); + + group('CartItemRemoved', () { + final item = FakeItem(); + test('supports value comparison', () { + expect(CartItemRemoved(item), CartItemRemoved(item)); + }); + }); }); } diff --git a/examples/flutter_shopping_cart/test/cart/view/cart_page_test.dart b/examples/flutter_shopping_cart/test/cart/view/cart_page_test.dart index f2d038ba5fa..676540f9f96 100644 --- a/examples/flutter_shopping_cart/test/cart/view/cart_page_test.dart +++ b/examples/flutter_shopping_cart/test/cart/view/cart_page_test.dart @@ -125,5 +125,18 @@ void main() { expect(find.byType(SnackBar), findsOneWidget); expect(find.text('Buying not supported yet.'), findsOneWidget); }); + + testWidgets('adds CartItemRemoved on long press', (tester) async { + when(() => cartBloc.state).thenReturn( + CartLoaded(cart: Cart(items: mockItems)), + ); + final mockItemToRemove = mockItems.last; + await tester.pumpApp( + cartBloc: cartBloc, + child: Scaffold(body: CartList()), + ); + await tester.longPress(find.text(mockItemToRemove.name)); + verify(() => cartBloc.add(CartItemRemoved(mockItemToRemove))).called(1); + }); }); } diff --git a/examples/flutter_shopping_cart/test/catalog/bloc/catalog_bloc_test.dart b/examples/flutter_shopping_cart/test/catalog/bloc/catalog_bloc_test.dart index 1787fac29bf..93b5bd923ad 100644 --- a/examples/flutter_shopping_cart/test/catalog/bloc/catalog_bloc_test.dart +++ b/examples/flutter_shopping_cart/test/catalog/bloc/catalog_bloc_test.dart @@ -26,12 +26,12 @@ void main() { blocTest( 'emits [CatalogLoading, CatalogLoaded] ' 'when catalog is loaded successfully', - build: () { + setUp: () { when(shoppingRepository.loadCatalog).thenAnswer( (_) async => mockItemNames, ); - return CatalogBloc(shoppingRepository: shoppingRepository); }, + build: () => CatalogBloc(shoppingRepository: shoppingRepository), act: (bloc) => bloc.add(CatalogStarted()), expect: () => [ CatalogLoading(), @@ -43,10 +43,10 @@ void main() { blocTest( 'emits [CatalogLoading, CatalogError] ' 'when loading the catalog throws an exception', - build: () { + setUp: () { when(shoppingRepository.loadCatalog).thenThrow(Exception('Error')); - return CatalogBloc(shoppingRepository: shoppingRepository); }, + build: () => CatalogBloc(shoppingRepository: shoppingRepository), act: (bloc) => bloc.add(CatalogStarted()), expect: () => [ CatalogLoading(), diff --git a/examples/flutter_shopping_cart/test/shopping_repository_test.dart b/examples/flutter_shopping_cart/test/shopping_repository_test.dart index 3551e3dfeb3..dd6e3edd881 100644 --- a/examples/flutter_shopping_cart/test/shopping_repository_test.dart +++ b/examples/flutter_shopping_cart/test/shopping_repository_test.dart @@ -55,5 +55,18 @@ void main() { ); }); }); + + group('removeItemFromCart', () { + test('removes item from cart', () { + final item = Item(1, 'item #1'); + shoppingRepository + ..addItemToCart(item) + ..removeItemFromCart(item); + expect( + shoppingRepository.loadCartItems(), + completion(equals([])), + ); + }); + }); }); } diff --git a/examples/flutter_weather/test/theme/cubit/theme_cubit_test.dart b/examples/flutter_weather/test/theme/cubit/theme_cubit_test.dart index 9c92053c2af..04637f39713 100644 --- a/examples/flutter_weather/test/theme/cubit/theme_cubit_test.dart +++ b/examples/flutter_weather/test/theme/cubit/theme_cubit_test.dart @@ -20,22 +20,13 @@ class MockWeather extends Mock implements Weather { void main() { initHydratedBloc(); group('ThemeCubit', () { - late ThemeCubit themeCubit; - - setUp(() { - themeCubit = ThemeCubit(); - }); - - tearDown(() { - themeCubit.close(); - }); - test('initial state is correct', () { - expect(themeCubit.state, ThemeCubit.defaultColor); + expect(ThemeCubit().state, ThemeCubit.defaultColor); }); group('toJson/fromJson', () { test('work properly', () { + final themeCubit = ThemeCubit(); expect( themeCubit.fromJson(themeCubit.toJson(themeCubit.state)), themeCubit.state, diff --git a/examples/flutter_weather/test/weather/cubit/weather_cubit_test.dart b/examples/flutter_weather/test/weather/cubit/weather_cubit_test.dart index 3f56b0da66c..e198278445f 100644 --- a/examples/flutter_weather/test/weather/cubit/weather_cubit_test.dart +++ b/examples/flutter_weather/test/weather/cubit/weather_cubit_test.dart @@ -21,7 +21,6 @@ void main() { group('WeatherCubit', () { late weather_repository.Weather weather; late weather_repository.WeatherRepository weatherRepository; - late WeatherCubit weatherCubit; setUpAll(initHydratedBloc); @@ -31,21 +30,19 @@ void main() { when(() => weather.condition).thenReturn(weatherCondition); when(() => weather.location).thenReturn(weatherLocation); when(() => weather.temperature).thenReturn(weatherTemperature); - when(() => weatherRepository.getWeather(any())) - .thenAnswer((_) async => weather); - weatherCubit = WeatherCubit(weatherRepository); - }); - - tearDown(() { - weatherCubit.close(); + when( + () => weatherRepository.getWeather(any()), + ).thenAnswer((_) async => weather); }); test('initial state is correct', () { + final weatherCubit = WeatherCubit(weatherRepository); expect(weatherCubit.state, WeatherState()); }); group('toJson/fromJson', () { test('work properly', () { + final weatherCubit = WeatherCubit(weatherRepository); expect( weatherCubit.fromJson(weatherCubit.toJson(weatherCubit.state)), weatherCubit.state, @@ -56,21 +53,21 @@ void main() { group('fetchWeather', () { blocTest( 'emits nothing when city is null', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), act: (cubit) => cubit.fetchWeather(null), expect: () => [], ); blocTest( 'emits nothing when city is empty', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), act: (cubit) => cubit.fetchWeather(''), expect: () => [], ); blocTest( 'calls getWeather with correct city', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), act: (cubit) => cubit.fetchWeather(weatherLocation), verify: (_) { verify(() => weatherRepository.getWeather(weatherLocation)).called(1); @@ -79,11 +76,12 @@ void main() { blocTest( 'emits [loading, failure] when getWeather throws', - build: () { - when(() => weatherRepository.getWeather(any())) - .thenThrow(Exception('oops')); - return weatherCubit; + setUp: () { + when( + () => weatherRepository.getWeather(any()), + ).thenThrow(Exception('oops')); }, + build: () => WeatherCubit(weatherRepository), act: (cubit) => cubit.fetchWeather(weatherLocation), expect: () => [ WeatherState(status: WeatherStatus.loading), @@ -93,7 +91,7 @@ void main() { blocTest( 'emits [loading, success] when getWeather returns (celsius)', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), act: (cubit) => cubit.fetchWeather(weatherLocation), expect: () => [ WeatherState(status: WeatherStatus.loading), @@ -117,7 +115,7 @@ void main() { blocTest( 'emits [loading, success] when getWeather returns (fahrenheit)', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), seed: () => WeatherState(temperatureUnits: TemperatureUnits.fahrenheit), act: (cubit) => cubit.fetchWeather(weatherLocation), expect: () => [ @@ -147,7 +145,7 @@ void main() { group('refreshWeather', () { blocTest( 'emits nothing when status is not success', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), act: (cubit) => cubit.refreshWeather(), expect: () => [], verify: (_) { @@ -157,7 +155,7 @@ void main() { blocTest( 'emits nothing when location is null', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), seed: () => WeatherState(status: WeatherStatus.success), act: (cubit) => cubit.refreshWeather(), expect: () => [], @@ -168,7 +166,7 @@ void main() { blocTest( 'invokes getWeather with correct location', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), seed: () => WeatherState( status: WeatherStatus.success, weather: Weather( @@ -186,11 +184,12 @@ void main() { blocTest( 'emits nothing when exception is thrown', - build: () { - when(() => weatherRepository.getWeather(any())) - .thenThrow(Exception('oops')); - return weatherCubit; + setUp: () { + when( + () => weatherRepository.getWeather(any()), + ).thenThrow(Exception('oops')); }, + build: () => WeatherCubit(weatherRepository), seed: () => WeatherState( status: WeatherStatus.success, weather: Weather( @@ -206,7 +205,7 @@ void main() { blocTest( 'emits updated weather (celsius)', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), seed: () => WeatherState( status: WeatherStatus.success, weather: Weather( @@ -238,7 +237,7 @@ void main() { blocTest( 'emits updated weather (fahrenheit)', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), seed: () => WeatherState( temperatureUnits: TemperatureUnits.fahrenheit, status: WeatherStatus.success, @@ -273,7 +272,7 @@ void main() { group('toggleUnits', () { blocTest( 'emits updated units when status is not success', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), act: (cubit) => cubit.toggleUnits(), expect: () => [ WeatherState(temperatureUnits: TemperatureUnits.fahrenheit), @@ -283,7 +282,7 @@ void main() { blocTest( 'emits updated units and temperature ' 'when status is success (celsius)', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), seed: () => WeatherState( status: WeatherStatus.success, temperatureUnits: TemperatureUnits.fahrenheit, @@ -312,7 +311,7 @@ void main() { blocTest( 'emits updated units and temperature ' 'when status is success (fahrenheit)', - build: () => weatherCubit, + build: () => WeatherCubit(weatherRepository), seed: () => WeatherState( status: WeatherStatus.success, temperatureUnits: TemperatureUnits.celsius,