diff --git a/assets/lotties/loading.json b/assets/lotties/loading.json new file mode 100644 index 0000000..225cb90 --- /dev/null +++ b/assets/lotties/loading.json @@ -0,0 +1 @@ +{"v":"5.8.1","fr":30,"ip":0,"op":60,"w":300,"h":300,"nm":"loading_6","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":60,"s":[360]}],"ix":10},"p":{"a":0,"k":[150.00000000000003,150.00000000000003,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[30.000000000000004,30.000000000000004,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[300,300],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.22745098039215686,0.6627450980392157,0.8627450980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":50,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"t":60,"s":[99]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[1]},{"t":50,"s":[100]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":60,"s":[3]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":30,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[150.00000000000003,150.00000000000003,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[30.000000000000004,30.000000000000004,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[300,300],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.6666666666666666,0.8431372549019608,0.9215686274509803,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":50,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 82aa725..2c00bce 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -207,6 +207,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -221,6 +222,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5dbdd63..2689ed4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,5 +43,7 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + diff --git a/lib/design_system/themes/custom_asset.dart b/lib/design_system/themes/custom_asset.dart new file mode 100644 index 0000000..95d609a --- /dev/null +++ b/lib/design_system/themes/custom_asset.dart @@ -0,0 +1,7 @@ +enum CustomAsset { + loadingAnimation('assets/lotties/loading.json'); + + const CustomAsset(this.path); + + final String path; +} diff --git a/lib/design_system/widgets/screen_init_error.dart b/lib/design_system/widgets/screen_init_error.dart new file mode 100644 index 0000000..5ef8f1e --- /dev/null +++ b/lib/design_system/widgets/screen_init_error.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../../helpers/app_constants.dart'; + +class ScreenInitError extends StatelessWidget { + final VoidCallback onTryAgain; + final bool allowRetry; + + const ScreenInitError({ + super.key, + required this.allowRetry, + required this.onTryAgain, + }); + + @override + Widget build(BuildContext context) { + // TODO(DavidGrunheidt): Use CustomXXX theme and widget classes + return Padding( + padding: const EdgeInsets.all(12), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + genericExceptionMessage, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + if (allowRetry) + InkWell( + onTap: onTryAgain, + child: const Text('Try again'), + ) + else + Text( + 'Try again later', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ) + ], + ), + ), + ); + } +} diff --git a/lib/features/base_view_model.dart b/lib/features/base_view_model.dart new file mode 100644 index 0000000..93d80fd --- /dev/null +++ b/lib/features/base_view_model.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:mobx/mobx.dart'; + +part 'base_view_model.g.dart'; + +class BaseViewModel = _BaseViewModel with _$BaseViewModel; + +abstract class _BaseViewModel with Store { + @observable + var loading = false; + + @observable + var firstLoading = true; + + @observable + var initFailed = false; + + @computed + bool get maxRetriesReached => initRetryCounter > 5; + + @observable + var initRetryCounter = 0; + + @computed + bool get allowInitRetry => !maxRetriesReached; + + @action + @nonVirtual + Future runInit() async { + try { + initFailed = false; + await init(); + initRetryCounter = 0; + } catch (exception, stackTrace) { + // TODO send error to error reporting tool + debugPrint('Error initilializing viewModel: ${exception.toString()}:\n$stackTrace'); + + initFailed = true; + initRetryCounter++; + } finally { + firstLoading = false; + loading = false; + } + } + + @action + @protected + Future init() async {} + + void dispose() {} +} diff --git a/lib/features/base_view_model_container.dart b/lib/features/base_view_model_container.dart new file mode 100644 index 0000000..8c03f1b --- /dev/null +++ b/lib/features/base_view_model_container.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:lottie/lottie.dart'; + +import '../design_system/themes/custom_asset.dart'; +import '../design_system/widgets/screen_init_error.dart'; +import 'base_view_model.dart'; + +class BaseViewModelContainer extends StatefulWidget { + final BaseViewModel viewModel; + final Widget child; + + const BaseViewModelContainer({ + super.key, + required this.viewModel, + required this.child, + }); + + @override + State createState() => _BaseViewModelContainerState(); +} + +class _BaseViewModelContainerState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => widget.viewModel.runInit()); + } + + @override + void dispose() { + widget.viewModel.dispose(); + super.dispose(); + } + + void closeKeyboard(BuildContext context) => FocusScope.of(context).unfocus(); + + Widget get loadingScreen => Container( + height: MediaQuery.of(context).size.height, + decoration: BoxDecoration( + color: Colors.white30.withOpacity(0.7), + ), + alignment: Alignment.center, + child: Center( + child: Lottie.asset(CustomAsset.loadingAnimation.path), + ), + ); + + @override + Widget build(BuildContext context) { + final viewModel = widget.viewModel; + return GestureDetector( + onTap: () => closeKeyboard(context), + child: Observer( + builder: (context) { + return Stack( + children: [ + if (!viewModel.initFailed) widget.child, + if (viewModel.initFailed) + ScreenInitError(allowRetry: viewModel.allowInitRetry, onTryAgain: viewModel.runInit), + if (viewModel.loading) loadingScreen, + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/new_posts/new_post_view_model.dart b/lib/features/new_posts/new_post_view_model.dart index f34002f..3ed3489 100644 --- a/lib/features/new_posts/new_post_view_model.dart +++ b/lib/features/new_posts/new_post_view_model.dart @@ -5,12 +5,13 @@ import 'package:mobx/mobx.dart'; import '../../helpers/dependencies/repository_locator.dart'; import '../../models/post.dart'; import '../../repository/post_repository.dart'; +import '../base_view_model.dart'; part 'new_post_view_model.g.dart'; -class NewPostViewModel = NewPostViewModelBase with _$NewPostViewModel; +class NewPostViewModel = _NewPostViewModel with _$NewPostViewModel; -abstract class NewPostViewModelBase with Store { +abstract class _NewPostViewModel extends BaseViewModel with Store { final PostRepository _postRepository = repositoryLocator(); Future addNewPost(String text, String creationDate) { diff --git a/lib/features/posts/post_view.dart b/lib/features/posts/post_view.dart index 7190dc4..96b8526 100644 --- a/lib/features/posts/post_view.dart +++ b/lib/features/posts/post_view.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart'; import '../../design_system/widgets/refresh_list_adaptive.dart'; import '../../flavors.dart'; import '../../models/post.dart'; +import '../base_view_model_container.dart'; import '../new_posts/new_post_view.dart'; import 'post_view_model.dart'; import 'widgets/post_card_item.dart'; @@ -35,22 +36,25 @@ class _PostViewState extends State { ) ], ), - body: Observer( - builder: (_) { - return RefreshListAdaptive( - onRefresh: _viewModel.loadPosts, - itemBuilder: (context, i) { - final post = _viewModel.postsList[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.postsList.length, - ); - }, + body: BaseViewModelContainer( + viewModel: _viewModel, + child: Observer( + builder: (_) { + return RefreshListAdaptive( + onRefresh: _viewModel.loadPosts, + itemBuilder: (context, i) { + final post = _viewModel.postsList[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.postsList.length, + ); + }, + ), ), ); } diff --git a/lib/features/posts/post_view_model.dart b/lib/features/posts/post_view_model.dart index 8a3f251..00c6a7f 100644 --- a/lib/features/posts/post_view_model.dart +++ b/lib/features/posts/post_view_model.dart @@ -3,18 +3,25 @@ import 'package:mobx/mobx.dart'; import '../../helpers/dependencies/repository_locator.dart'; import '../../models/post.dart'; import '../../repository/post_repository.dart'; +import '../base_view_model.dart'; part 'post_view_model.g.dart'; -class PostViewModel = PostViewModelBase with _$PostViewModel; +class PostViewModel = _PostsViewModel with _$PostViewModel; -// TODO(DavidGrunheidt): Add BaseViewModel extension with override to init method -abstract class PostViewModelBase with Store { +abstract class _PostsViewModel extends BaseViewModel with Store { final PostRepository _postRepository = repositoryLocator(); @computed List get postsList => _postRepository.postsList; + @action + @override + Future init() async { + await loadPosts(); + return super.init(); + } + @action Future loadPosts() => _postRepository.loadAll(); } diff --git a/lib/helpers/app_constants.dart b/lib/helpers/app_constants.dart new file mode 100644 index 0000000..3260855 --- /dev/null +++ b/lib/helpers/app_constants.dart @@ -0,0 +1 @@ +const genericExceptionMessage = 'Oh no! Something went wrong.'; diff --git a/pubspec.yaml b/pubspec.yaml index 6f2f4b0..6427dbf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,8 +5,8 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - flutter: ">=3.7.1 <4.0.0" - sdk: ">=2.19.1 <3.0.0" + flutter: ">=3.7.3 <4.0.0" + sdk: ">=2.19.2 <3.0.0" dependencies: flutter: @@ -25,6 +25,9 @@ dependencies: # Data json_annotation: ^4.8.0 + # UI: + lottie: ^2.2.0 + dev_dependencies: flutter_test: sdk: flutter @@ -46,12 +49,13 @@ dev_dependencies: faker: ^2.1.0 http_mock_adapter: ^0.3.3 -dependency_overrides: - collection: ^1.17.0 - flutter: uses-material-design: true + # To add assets to your application, add an assets section, like this: + assets: + - assets/lotties/ + flavorizr: app: android: