diff --git a/db.json b/db.json index bfe6869..4f8765f 100644 --- a/db.json +++ b/db.json @@ -34,21 +34,6 @@ "text": "test 3", "creation-date": "2021-06-16 11:29:50.054625", "id": 8 - }, - { - "text": "", - "creation-date": "2021-06-16 11:56:00.178866", - "id": 9 - }, - { - "text": "", - "creation-date": "2021-06-16 11:56:21.636490", - "id": 10 - }, - { - "text": "", - "creation-date": "2021-06-16 11:56:32.360321", - "id": 11 } ] } \ No newline at end of file diff --git a/lib/modules/posts/widgets/header_input_text.dart b/lib/modules/posts/ui/widgets/header_input_text.dart similarity index 100% rename from lib/modules/posts/widgets/header_input_text.dart rename to lib/modules/posts/ui/widgets/header_input_text.dart diff --git a/lib/modules/posts/widgets/input_text.dart b/lib/modules/posts/ui/widgets/input_text.dart similarity index 78% rename from lib/modules/posts/widgets/input_text.dart rename to lib/modules/posts/ui/widgets/input_text.dart index e398234..f39be37 100644 --- a/lib/modules/posts/widgets/input_text.dart +++ b/lib/modules/posts/ui/widgets/input_text.dart @@ -3,11 +3,11 @@ import 'package:flutter/material.dart'; class InputText extends StatelessWidget { const InputText({ Key? key, - required this.textEditingController, + required this.onChanged, required this.validator, }) : super(key: key); - final TextEditingController textEditingController; + final void Function(String)? onChanged; final String? Function(String?)? validator; @override @@ -15,7 +15,8 @@ class InputText extends StatelessWidget { return Container( margin: const EdgeInsets.fromLTRB(0, 0, 0, 15), child: TextFormField( - controller: textEditingController, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: onChanged, decoration: const InputDecoration( hintText: 'type the text to you post', border: OutlineInputBorder(), diff --git a/lib/modules/posts/widgets/post_card_item.dart b/lib/modules/posts/ui/widgets/post_card_item.dart similarity index 100% rename from lib/modules/posts/widgets/post_card_item.dart rename to lib/modules/posts/ui/widgets/post_card_item.dart diff --git a/lib/modules/posts/view_model/new_post_view_model.dart b/lib/modules/posts/view_model/new_post_view_model.dart index b9aa9c1..c4bcd3c 100644 --- a/lib/modules/posts/view_model/new_post_view_model.dart +++ b/lib/modules/posts/view_model/new_post_view_model.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; + import 'package:flutter_mobx_template/models/post.dart'; import 'package:flutter_mobx_template/repository/i_post_repository.dart'; -import 'package:flutter_mobx_template/ui/functions/show_adaptive_dialog.dart'; import 'package:mobx/mobx.dart'; part 'new_post_view_model.g.dart'; @@ -14,14 +14,7 @@ abstract class NewPostViewModelBase with Store { final IPostRepository _repository; @observable - bool _isSaving = false; - @observable - GlobalKey formKey = GlobalKey(); - @observable - TextEditingController textController = TextEditingController(text: ''); - - @computed - bool get isSaving => _isSaving; + String text = ''; @action String? validateTextPost(String? value) { @@ -31,36 +24,29 @@ abstract class NewPostViewModelBase with Store { return null; } + @observable + ObservableFuture savePostFuture = ObservableFuture.error(''); + + @computed + String get errorMessage => savePostFuture.status == FutureStatus.rejected + ? savePostFuture.error.toString() + : ''; + + @observable + bool isSavingPost = false; + @action - Future addNewPost(BuildContext context) async { + Future addNewPost() async { try { - _isSaving = true; - if (!formKey.currentState!.validate()) { - throw const FormatException('The Form is invalid'); - } - final Post post = await _repository.add( - text: textController.text, - creationDate: DateTime.now().toString(), - ); - textController.clear(); // empty TextEditingController - Navigator.of(context).pop(post); // close BottomSheet - _isSaving = false; + isSavingPost = true; + savePostFuture = _repository + .add(text: text, creationDate: DateTime.now().toString()) + .asObservable(); + final Post post = await savePostFuture; + isSavingPost = false; return post; - } on FormatException catch (e, stackTrace) { - _isSaving = false; - showAdaptiveDialog( - context: context, - title: 'Error', - content: e.message, - ); - return Future.error(e, stackTrace); } catch (e, stackTrace) { - _isSaving = false; - await showAdaptiveDialog( - context: context, - title: 'Error', - content: e.toString(), - ); + isSavingPost = false; return Future.error(e, stackTrace); } } diff --git a/lib/modules/posts/view_model/new_post_view_model.g.dart b/lib/modules/posts/view_model/new_post_view_model.g.dart index 2fd5a9d..d3013a4 100644 --- a/lib/modules/posts/view_model/new_post_view_model.g.dart +++ b/lib/modules/posts/view_model/new_post_view_model.g.dart @@ -9,57 +9,57 @@ part of 'new_post_view_model.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic mixin _$NewPostViewModel on NewPostViewModelBase, Store { - Computed? _$isSavingComputed; + Computed? _$errorMessageComputed; @override - bool get isSaving => - (_$isSavingComputed ??= Computed(() => super.isSaving, - name: 'NewPostViewModelBase.isSaving')) + String get errorMessage => + (_$errorMessageComputed ??= Computed(() => super.errorMessage, + name: 'NewPostViewModelBase.errorMessage')) .value; - final _$_isSavingAtom = Atom(name: 'NewPostViewModelBase._isSaving'); + final _$textAtom = Atom(name: 'NewPostViewModelBase.text'); @override - bool get _isSaving { - _$_isSavingAtom.reportRead(); - return super._isSaving; + String get text { + _$textAtom.reportRead(); + return super.text; } @override - set _isSaving(bool value) { - _$_isSavingAtom.reportWrite(value, super._isSaving, () { - super._isSaving = value; + set text(String value) { + _$textAtom.reportWrite(value, super.text, () { + super.text = value; }); } - final _$formKeyAtom = Atom(name: 'NewPostViewModelBase.formKey'); + final _$savePostFutureAtom = + Atom(name: 'NewPostViewModelBase.savePostFuture'); @override - GlobalKey get formKey { - _$formKeyAtom.reportRead(); - return super.formKey; + ObservableFuture get savePostFuture { + _$savePostFutureAtom.reportRead(); + return super.savePostFuture; } @override - set formKey(GlobalKey value) { - _$formKeyAtom.reportWrite(value, super.formKey, () { - super.formKey = value; + set savePostFuture(ObservableFuture value) { + _$savePostFutureAtom.reportWrite(value, super.savePostFuture, () { + super.savePostFuture = value; }); } - final _$textControllerAtom = - Atom(name: 'NewPostViewModelBase.textController'); + final _$isSavingPostAtom = Atom(name: 'NewPostViewModelBase.isSavingPost'); @override - TextEditingController get textController { - _$textControllerAtom.reportRead(); - return super.textController; + bool get isSavingPost { + _$isSavingPostAtom.reportRead(); + return super.isSavingPost; } @override - set textController(TextEditingController value) { - _$textControllerAtom.reportWrite(value, super.textController, () { - super.textController = value; + set isSavingPost(bool value) { + _$isSavingPostAtom.reportWrite(value, super.isSavingPost, () { + super.isSavingPost = value; }); } @@ -67,8 +67,8 @@ mixin _$NewPostViewModel on NewPostViewModelBase, Store { AsyncAction('NewPostViewModelBase.addNewPost'); @override - Future addNewPost(BuildContext context) { - return _$addNewPostAsyncAction.run(() => super.addNewPost(context)); + Future addNewPost() { + return _$addNewPostAsyncAction.run(() => super.addNewPost()); } final _$NewPostViewModelBaseActionController = @@ -88,9 +88,10 @@ mixin _$NewPostViewModel on NewPostViewModelBase, Store { @override String toString() { return ''' -formKey: ${formKey}, -textController: ${textController}, -isSaving: ${isSaving} +text: ${text}, +savePostFuture: ${savePostFuture}, +isSavingPost: ${isSavingPost}, +errorMessage: ${errorMessage} '''; } } diff --git a/lib/modules/posts/view_model/post_view_model.dart b/lib/modules/posts/view_model/post_view_model.dart index 2a41379..2887ab4 100644 --- a/lib/modules/posts/view_model/post_view_model.dart +++ b/lib/modules/posts/view_model/post_view_model.dart @@ -1,9 +1,6 @@ -import 'dart:developer'; +import 'dart:async'; -import 'package:flutter/material.dart'; import 'package:flutter_mobx_template/models/post.dart'; -import 'package:flutter_mobx_template/modules/posts/view_model/new_post_view_model.dart'; -import 'package:flutter_mobx_template/modules/posts/views/new_post_view.dart'; import 'package:flutter_mobx_template/repository/i_post_repository.dart'; import 'package:mobx/mobx.dart'; @@ -21,43 +18,26 @@ abstract class PostViewModelBase with Store { @observable ObservableList posts = ObservableList.of([]); - @action - Future loadPosts({BuildContext? context}) async { - try { - posts = (await _repository.getAll()).asObservable(); - } catch (e, stackTrace) { - log(e.toString(), - name: 'PostViewModel.loadPosts', stackTrace: stackTrace); - if (context != null) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.toString()))); - } - } - } + @observable + ObservableFuture> getPostsFuture = + ObservableFuture>.value([]); + + @computed + String get errorMessage => getPostsFuture.status == FutureStatus.rejected + ? getPostsFuture.error.toString() + : ''; - Future openNewPostBottomSheet( - BuildContext context, - PostViewModel postViewModel, - ) async { + @computed + bool get hasData => + getPostsFuture.status == FutureStatus.fulfilled && posts.isNotEmpty; + + @action + Future loadPosts() async { try { - final Post? post = await showModalBottomSheet( - context: context, - builder: (BuildContext context) { - return NewPostPage(newPostViewModel: NewPostViewModel(_repository)); - }, - backgroundColor: Colors.black54, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(5), - topRight: Radius.circular(5), - ), - ), - ); - if (post != null) { - loadPosts(); - } + getPostsFuture = _repository.getAll().asObservable(); + posts = (await getPostsFuture).asObservable(); } catch (e, stackTrace) { - log(e.toString(), name: 'openNewPostBottomSheet', stackTrace: stackTrace); + return Future.error(e, stackTrace); } } } diff --git a/lib/modules/posts/view_model/post_view_model.g.dart b/lib/modules/posts/view_model/post_view_model.g.dart index b752688..a770c2d 100644 --- a/lib/modules/posts/view_model/post_view_model.g.dart +++ b/lib/modules/posts/view_model/post_view_model.g.dart @@ -9,6 +9,20 @@ part of 'post_view_model.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic mixin _$PostViewModel on PostViewModelBase, Store { + Computed? _$errorMessageComputed; + + @override + String get errorMessage => + (_$errorMessageComputed ??= Computed(() => super.errorMessage, + name: 'PostViewModelBase.errorMessage')) + .value; + Computed? _$hasDataComputed; + + @override + bool get hasData => (_$hasDataComputed ??= Computed(() => super.hasData, + name: 'PostViewModelBase.hasData')) + .value; + final _$postsAtom = Atom(name: 'PostViewModelBase.posts'); @override @@ -24,17 +38,35 @@ mixin _$PostViewModel on PostViewModelBase, Store { }); } + final _$getPostsFutureAtom = Atom(name: 'PostViewModelBase.getPostsFuture'); + + @override + ObservableFuture> get getPostsFuture { + _$getPostsFutureAtom.reportRead(); + return super.getPostsFuture; + } + + @override + set getPostsFuture(ObservableFuture> value) { + _$getPostsFutureAtom.reportWrite(value, super.getPostsFuture, () { + super.getPostsFuture = value; + }); + } + final _$loadPostsAsyncAction = AsyncAction('PostViewModelBase.loadPosts'); @override - Future loadPosts({BuildContext? context}) { - return _$loadPostsAsyncAction.run(() => super.loadPosts(context: context)); + Future loadPosts() { + return _$loadPostsAsyncAction.run(() => super.loadPosts()); } @override String toString() { return ''' -posts: ${posts} +posts: ${posts}, +getPostsFuture: ${getPostsFuture}, +errorMessage: ${errorMessage}, +hasData: ${hasData} '''; } } diff --git a/lib/modules/posts/views/new_post_view.dart b/lib/modules/posts/views/new_post_view.dart index 586b622..ff99f21 100644 --- a/lib/modules/posts/views/new_post_view.dart +++ b/lib/modules/posts/views/new_post_view.dart @@ -1,19 +1,38 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:flutter_mobx_template/modules/posts/view_model/new_post_view_model.dart'; +import 'package:flutter_mobx_template/models/post.dart'; -import 'package:flutter_mobx_template/modules/posts/widgets/header_input_text.dart'; -import 'package:flutter_mobx_template/modules/posts/widgets/input_text.dart'; +import 'package:flutter_mobx_template/modules/posts/ui/widgets/header_input_text.dart'; +import 'package:flutter_mobx_template/modules/posts/ui/widgets/input_text.dart'; +import 'package:flutter_mobx_template/modules/posts/view_model/new_post_view_model.dart'; +import 'package:flutter_mobx_template/ui/functions/show_adaptive_dialog.dart'; import 'package:flutter_mobx_template/ui/widgets/buttons/button_with_icon_full_size.dart'; import 'package:flutter_mobx_template/ui/widgets/center_loading.dart'; class NewPostPage extends StatelessWidget { - const NewPostPage({ + NewPostPage({ Key? key, - required this.newPostViewModel, + required this.viewModel, }) : super(key: key); - final NewPostViewModel newPostViewModel; + final NewPostViewModel viewModel; + final GlobalKey _formKey = GlobalKey(); + + Future saveNewPost(BuildContext context) async { + if (_formKey.currentState!.validate()) { + try { + final Post post = await viewModel.addNewPost(); + // close BottomSheet and return post to previus page + Navigator.of(context).pop(post); + } catch (e) { + showAdaptiveDialog( + context: context, + title: 'Error', + content: viewModel.errorMessage, + ); + } + } + } @override Widget build(BuildContext context) { @@ -30,25 +49,28 @@ class NewPostPage extends StatelessWidget { ), child: SingleChildScrollView( child: Form( - key: newPostViewModel.formKey, + key: _formKey, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ const HeaderInputText(text: 'New Post'), InputText( - textEditingController: newPostViewModel.textController, - validator: newPostViewModel.validateTextPost, + onChanged: (String value) => viewModel.text = value, + validator: viewModel.validateTextPost, ), Observer( builder: (_) { - return newPostViewModel.isSaving - ? const CenterLoading() - : ButtonWithIconFullSize( - onPressed: () => newPostViewModel.addNewPost(context), - text: 'save', - icon: const Icon(Icons.add), - ); + if (viewModel.isSavingPost) { + return const CenterLoading(); + } + return ButtonWithIconFullSize( + onPressed: viewModel.text.isNotEmpty + ? () => saveNewPost(context) + : null, + text: 'save', + icon: const Icon(Icons.add), + ); }, ), ], diff --git a/lib/modules/posts/views/post_view.dart b/lib/modules/posts/views/post_view.dart index 2645592..05f82ce 100644 --- a/lib/modules/posts/views/post_view.dart +++ b/lib/modules/posts/views/post_view.dart @@ -1,50 +1,124 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:flutter_mobx_template/modules/posts/view_model/new_post_view_model.dart'; +import 'package:flutter_mobx_template/modules/posts/views/new_post_view.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + import 'package:flutter_mobx_template/flavors.dart'; import 'package:flutter_mobx_template/models/post.dart'; +import 'package:flutter_mobx_template/modules/posts/ui/widgets/post_card_item.dart'; import 'package:flutter_mobx_template/modules/posts/view_model/post_view_model.dart'; -import 'package:flutter_mobx_template/modules/posts/widgets/post_card_item.dart'; +import 'package:flutter_mobx_template/repository/implementation/post_repository.dart'; import 'package:flutter_mobx_template/ui/refresh_list_adaptive.dart'; -import 'package:intl/intl.dart'; +import 'package:flutter_mobx_template/ui/widgets/center_loading.dart'; class PostView extends StatelessWidget { - const PostView({ + PostView({ Key? key, - required this.postViewModel, + required this.viewModel, }) : super(key: key); - final PostViewModel postViewModel; + final PostViewModel viewModel; + + final GlobalKey _scaffoldMessengerKey = + GlobalKey(); + + Future openBottomSheetNewPost({ + required BuildContext context, + required PostRepository repository, + required Future Function() reloadPosts, + }) async { + final Post? result = await showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return NewPostPage(viewModel: NewPostViewModel(repository)); + }, + backgroundColor: Colors.black54, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(5), + topRight: Radius.circular(5), + ), + ), + ); + if (result != null) { + reloadPosts(); + } + } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(F.env.title), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () => - postViewModel.openNewPostBottomSheet(context, postViewModel), - ) - ], - ), - body: Observer( - builder: (_) { - return RefreshListAdaptive( - onRefresh: () => postViewModel.loadPosts(context: context), - itemBuilder: (BuildContext context, int i) { - final Post post = postViewModel.posts[i]; - return PostCardItem( - key: Key(post.id.toString()), - id: post.id.toString(), - text: post.text, - createdAt: - DateFormat.yMd().add_Hms().format(post.createdAtDatetime), + return ScaffoldMessenger( + key: _scaffoldMessengerKey, + child: Scaffold( + appBar: AppBar( + title: Text(F.env.title), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () async { + openBottomSheetNewPost( + context: context, + reloadPosts: viewModel.loadPosts, + repository: + Provider.of(context, listen: false), + ); + }, + ) + ], + ), + body: Observer( + builder: (_) { + if (!viewModel.hasData) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(bottom: 10), + child: const CenterLoading(), + ), + ElevatedButton.icon( + onPressed: viewModel.loadPosts, + label: const Text('Refresh'), + icon: const Icon(Icons.refresh), + ) + ], ); - }, - itemCount: postViewModel.posts.length, - ); - }, + } + + if (viewModel.errorMessage.isNotEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(viewModel.errorMessage), + ElevatedButton.icon( + onPressed: viewModel.loadPosts, + label: const Text('Refresh'), + icon: const Icon(Icons.refresh), + ), + ], + ); + } + + return RefreshListAdaptive( + onRefresh: viewModel.loadPosts, + itemBuilder: (BuildContext context, int i) { + final Post post = viewModel.posts[i]; + return PostCardItem( + key: Key(post.id.toString()), + id: post.id.toString(), + text: post.text, + createdAt: + DateFormat.yMd().add_Hms().format(post.createdAtDatetime), + ); + }, + itemCount: viewModel.posts.length, + ); + }, + ), ), ); } diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index bffb45d..bf46f27 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; final Map routes = { RouteNames.posts: (BuildContext context) => PostView( - postViewModel: PostViewModel( + viewModel: PostViewModel( Provider.of(context, listen: false), ), ), diff --git a/lib/ui/widgets/buttons/button_with_icon_full_size.dart b/lib/ui/widgets/buttons/button_with_icon_full_size.dart index 8794627..9fef4a3 100644 --- a/lib/ui/widgets/buttons/button_with_icon_full_size.dart +++ b/lib/ui/widgets/buttons/button_with_icon_full_size.dart @@ -8,7 +8,7 @@ class ButtonWithIconFullSize extends StatelessWidget { required this.icon, }) : super(key: key); - final void Function() onPressed; + final void Function()? onPressed; final String text; final Icon icon; diff --git a/test/modules/posts/view_model/new_post_view_model_test.dart b/test/modules/posts/view_model/new_post_view_model_test.dart index a2b382c..e24169b 100644 --- a/test/modules/posts/view_model/new_post_view_model_test.dart +++ b/test/modules/posts/view_model/new_post_view_model_test.dart @@ -1,8 +1,6 @@ import 'package:faker/faker.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_mobx_template/models/post.dart'; import 'package:flutter_mobx_template/modules/posts/view_model/new_post_view_model.dart'; -import 'package:flutter_mobx_template/modules/posts/views/new_post_view.dart'; import 'package:flutter_mobx_template/repository/implementation/post_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; @@ -35,8 +33,7 @@ void main() { }); group('Save new post', () { - testWidgets('should return success new add new post', - (WidgetTester tester) async { + test('should return success new add new post', () async { const int id = 1; final String text = Faker().randomGenerator.string(20, min: 10); final String creationDate = Faker().date.dateTime().toString(); @@ -46,21 +43,14 @@ void main() { )).thenAnswer( (_) async => Post(id: id, text: text, creationDate: creationDate)); - // Get one valid context - await tester.pumpWidget(MaterialApp( - home: Material( - child: NewPostPage(newPostViewModel: _newPostViewModel)))); - final BuildContext context = tester.element(find.byType(NewPostPage)); - - _newPostViewModel.textController.text = text; - final Post post = await _newPostViewModel.addNewPost(context); + _newPostViewModel.text = text; + final Post post = await _newPostViewModel.addNewPost(); expect(post.id, id); expect(post.text, text); expect(post.creationDate, creationDate); }); - testWidgets('should return exception when the form is invalid', - (WidgetTester tester) async { + test('should return exception when the form is invalid', () async { const int id = 1; final String text = Faker().randomGenerator.string(20, min: 10); final String creationDate = Faker().date.dateTime().toString(); @@ -70,13 +60,9 @@ void main() { )).thenAnswer( (_) async => Post(id: id, text: text, creationDate: creationDate)); - // Get one valid context - await tester.pumpWidget(MaterialApp( - home: Material( - child: NewPostPage(newPostViewModel: _newPostViewModel)))); - final BuildContext context = tester.element(find.byType(NewPostPage)); try { - await _newPostViewModel.addNewPost(context); + _newPostViewModel.text = text; + await _newPostViewModel.addNewPost(); fail('Failed test'); } catch (e) { expect(e, isInstanceOf());