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: