Skip to content

Commit

Permalink
Merge pull request #127 from ProximaEPFL/circular-value-error
Browse files Browse the repository at this point in the history
Circular value error handling
  • Loading branch information
camillelnne authored Apr 18, 2024
2 parents 106f509 + ebc4764 commit c33a501
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 43 deletions.
25 changes: 23 additions & 2 deletions lib/utils/ui/circular_value.dart
Original file line number Diff line number Diff line change
@@ -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<T> extends StatelessWidget {
final AsyncValue<T> 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(),
);
Expand Down
32 changes: 32 additions & 0 deletions lib/utils/ui/error_alert.dart
Original file line number Diff line number Diff line change
@@ -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: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
key: okButtonKey,
child: const Text("OK"),
),
],
);
}
}
17 changes: 9 additions & 8 deletions lib/viewmodels/new_post_view_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,22 @@ class NewPostViewModel extends AutoDisposeAsyncNotifier<NewPostState> {
}

/// 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<void> addPost(String title, String description) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => _addPost(title, description));
}

Future<NewPostState> _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 =
Expand All @@ -65,12 +68,10 @@ class NewPostViewModel extends AutoDisposeAsyncNotifier<NewPostState> {

await postRepository.addPost(post, currPosition);

state = AsyncData(
NewPostState(
titleError: null,
descriptionError: null,
posted: true,
),
return NewPostState(
titleError: null,
descriptionError: null,
posted: true,
);
}
}
Expand Down
62 changes: 29 additions & 33 deletions lib/views/home_content/feed/post_feed.dart
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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;
},
),
),
],
);
Expand Down
72 changes: 72 additions & 0 deletions test/component/utils/ui/circular_value_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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);
});
}

0 comments on commit c33a501

Please sign in to comment.