diff --git a/lib/utils/ui/circular_value.dart b/lib/utils/ui/circular_value.dart index 8a907baa..fed958e2 100644 --- a/lib/utils/ui/circular_value.dart +++ b/lib/utils/ui/circular_value.dart @@ -1,23 +1,44 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:proxima/utils/ui/error_alert.dart"; /// Utilitiy widget used to display a [CircularProgressIndicator] while waiting /// for an [AsyncValue] to complete; and another widget once the data resolves. +/// In case the data resolves to an error, an [ErrorAlert] dialog is shown, and +/// a fallback widget is displayed. The default fallback widget is empty, but it +/// can be overridden. class CircularValue extends StatelessWidget { final AsyncValue value; final Widget Function(BuildContext context, T data) builder; + final Widget Function(BuildContext context, Object error) fallbackBuilder; + static Widget _defaultFallback(BuildContext context, Object error) => + const SizedBox.shrink(); + + /// Constructor for the [CircularValue] widget. + /// [value] is the underlying [AsyncValue] that controls the display. + /// [builder] is the widget to display when the [value] is [AsyncValue.data]. + /// [fallbackBuilder] is the widget to display when the [value] is [AsyncValue.error]. + /// The default [fallbackBuilder] is an empty [SizedBox]. const CircularValue({ super.key, required this.value, required this.builder, + this.fallbackBuilder = _defaultFallback, }); @override Widget build(BuildContext context) { - return value.maybeWhen( + return value.when( data: (data) => builder(context, data), - orElse: () { + error: (error, _) { + final dialog = ErrorAlert(error: error); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + showDialog(context: context, builder: dialog.build); + }); + return fallbackBuilder(context, error); + }, + loading: () { return const Center( child: CircularProgressIndicator(), ); diff --git a/lib/utils/ui/error_alert.dart b/lib/utils/ui/error_alert.dart new file mode 100644 index 00000000..8755bbc5 --- /dev/null +++ b/lib/utils/ui/error_alert.dart @@ -0,0 +1,32 @@ +import "package:flutter/material.dart"; + +/// A simple alert dialog that displays an error message. +class ErrorAlert extends StatelessWidget { + static const okButtonKey = Key("okButton"); + + /// Constructor for the [ErrorAlert] widget. + /// [error] is the error object to display. + const ErrorAlert({ + super.key, + required this.error, + }); + + final Object error; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("An error occurred"), + content: Text(error.toString()), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + key: okButtonKey, + child: const Text("OK"), + ), + ], + ); + } +} diff --git a/lib/viewmodels/new_post_view_model.dart b/lib/viewmodels/new_post_view_model.dart index f45ed7dd..c4b9a7ff 100644 --- a/lib/viewmodels/new_post_view_model.dart +++ b/lib/viewmodels/new_post_view_model.dart @@ -36,19 +36,22 @@ class NewPostViewModel extends AutoDisposeAsyncNotifier { } /// Verifies that the title and description are not empty, then adds a new post to the database. - /// If the user is not logged in, an exception is thrown. /// If the title or description is empty, the state is updated with the appropriate error message. /// If the post is successfully added, the state is updated with the posted flag set to true. Future addPost(String title, String description) async { state = const AsyncLoading(); + state = await AsyncValue.guard(() => _addPost(title, description)); + } + Future _addPost(String title, String description) async { final currentUser = ref.read(uidProvider); if (currentUser == null) { throw Exception("User must be logged in before creating a post"); } if (!validate(title, description)) { - return; + // not loading or error since validation failed and wrote to the state + return state.value!; } final currPosition = @@ -65,12 +68,10 @@ class NewPostViewModel extends AutoDisposeAsyncNotifier { await postRepository.addPost(post, currPosition); - state = AsyncData( - NewPostState( - titleError: null, - descriptionError: null, - posted: true, - ), + return NewPostState( + titleError: null, + descriptionError: null, + posted: true, ); } } diff --git a/lib/views/home_content/feed/post_feed.dart b/lib/views/home_content/feed/post_feed.dart index b08a86d6..380299ec 100644 --- a/lib/views/home_content/feed/post_feed.dart +++ b/lib/views/home_content/feed/post_feed.dart @@ -1,6 +1,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:proxima/models/ui/post_overview.dart"; +import "package:proxima/utils/ui/circular_value.dart"; import "package:proxima/viewmodels/home_view_model.dart"; import "package:proxima/views/home_content/feed/post_card/post_card.dart"; import "package:proxima/views/navigation/routes.dart"; @@ -15,6 +16,7 @@ class PostFeed extends HookConsumerWidget { static const feedKey = Key("feed"); static const emptyfeedKey = Key("emptyFeed"); static const newPostButtonTextKey = Key("newPostButtonTextKey"); + const PostFeed({super.key}); @override @@ -56,46 +58,40 @@ class PostFeed extends HookConsumerWidget { ), ); + final fallback = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("An error occurred"), + const SizedBox(height: 10), + refreshButton, + ], + ), + ); + return Column( children: [ const FeedSortOptionChips( key: feedSortOptionKey, ), const Divider(), - asyncPosts.when( - data: (posts) { - final postsList = PostList( - posts: posts, - onRefresh: () async { - return ref.read(postOverviewProvider.notifier).refresh(); - }, - ); + Expanded( + child: CircularValue( + value: asyncPosts, + builder: (context, posts) { + final postsList = PostList( + posts: posts, + onRefresh: () async { + return ref.read(postOverviewProvider.notifier).refresh(); + }, + ); - return Expanded( - child: posts.isEmpty ? emptyHelper : postsList, - ); - }, - loading: () { - return const Expanded( - child: Center( - child: CircularProgressIndicator(), - ), - ); - }, - error: (error, _) { - return Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("An error occurred"), - const SizedBox(height: 10), - refreshButton, - ], - ), - ), - ); - }, + return posts.isEmpty ? emptyHelper : postsList; + }, + fallbackBuilder: (context, error) { + return fallback; + }, + ), ), ], ); diff --git a/test/component/utils/ui/circular_value_test.dart b/test/component/utils/ui/circular_value_test.dart new file mode 100644 index 00000000..9e4da04c --- /dev/null +++ b/test/component/utils/ui/circular_value_test.dart @@ -0,0 +1,72 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:proxima/utils/ui/circular_value.dart"; +import "package:proxima/utils/ui/error_alert.dart"; + +void main() { + Widget testCircularValue(AsyncValue value) => CircularValue( + value: value, + builder: (context, data) => const Text("Completed"), + fallbackBuilder: (context, error) => const Text("Strange Error"), + ); + + testWidgets( + "CircularValue should show CircularProgressIndicator when loading", ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: testCircularValue(const AsyncValue.loading()), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets("CicularValue should build with value when finished", ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: testCircularValue(const AsyncValue.data(null)), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text("Completed"), findsOneWidget); + }); + + testWidgets("CicularValue should build error when error", ( + tester, + ) async { + final testException = Exception("Blue moon"); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: testCircularValue( + AsyncValue.error(testException, StackTrace.empty), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // expect to find a popup dialog + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.textContaining("Blue moon"), findsOneWidget); + // find ok button + final okButton = find.byKey(ErrorAlert.okButtonKey); + expect(okButton, findsOneWidget); + await tester.tap(okButton); + await tester.pumpAndSettle(); + + expect(find.text("Strange Error"), findsOneWidget); + }); +}