Skip to content

Commit

Permalink
Merge pull request #314 from ProximaEPFL/offline-circular-value
Browse files Browse the repository at this point in the history
Offline Circular Value Adaptation
  • Loading branch information
gruvw authored May 27, 2024
2 parents 8a70195 + f7a504a commit 56aac34
Show file tree
Hide file tree
Showing 30 changed files with 312 additions and 95 deletions.
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<application
android:label="Proxima"
android:name="${applicationName}"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
Expand Down
119 changes: 88 additions & 31 deletions lib/views/components/async/circular_value.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:proxima/views/components/async/error_alert.dart";
import "package:proxima/views/components/async/logo_progress_indicator.dart";
import "package:proxima/views/components/async/offline_alert.dart";
import "package:proxima/views/helpers/types/result.dart";

/// Utilitiy widget used to display a [LogoProgressIndicator] while waiting
/// for an [AsyncValue] to complete; and another widget once the data resolves.
/// Utility widget used to display a [LogoProgressIndicator] while waiting for a
/// [Future] of type [Result] 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;
class CircularValue<T> extends HookWidget {
final Future<Result<T, Object?>> future;
final Widget Function(BuildContext context, T data) builder;
final Widget Function(BuildContext context, Object error) fallbackBuilder;

Expand All @@ -19,43 +21,98 @@ class CircularValue<T> extends StatelessWidget {
/// that should never occur for a real user of the app (not front facing errors).
static const debugErrorTag = "DEBUG";

/// Tag to be placed inside of an error message to inform
/// the circular value of an TIMEOUT (due to bad internet connectivity).
/// **Note**: the [debugErrorTag] takes precedence over [timeoutErrorTag].
static const timeoutErrorTag = "TIMEOUT";

/// Time after what the circular value will display an error message instead
/// of spinning for ever.
static const offlineTimeout = Duration(seconds: 8);

static Widget defaultFallback(BuildContext _, Object __) =>
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].
/// [future] is the underlying [Future] that controls the display.
/// [builder] is the widget to display when the [future] completes
/// with valid [Result.value].
/// [fallbackBuilder] is the widget to display when the [future] errors
/// or completes with [Result.error].
/// The default [fallbackBuilder] is an empty [SizedBox].
const CircularValue({
CircularValue({
super.key,
required this.value,
required Future<Result<T, Object?>> future,
required this.builder,
this.fallbackBuilder = _defaultFallback,
});

static Widget _defaultFallback(BuildContext _, Object __) =>
const SizedBox.shrink();
this.fallbackBuilder = defaultFallback,
}) : future = future
.timeout(offlineTimeout)
.onError((error, stackTrace) => Result.error(timeoutErrorTag));

@override
Widget build(BuildContext context) {
return value.when(
data: (data) => builder(context, data),
error: (error, _) {
final errorText = error.toString();
// Avoids showing the error dialog twice
final showedError = useState(false);

return FutureBuilder(
future: future,
builder: (context, snapshot) {
const loading = Center(
child: LogoProgressIndicator(),
);

final data = snapshot.data;

// Loading state
if (snapshot.connectionState != ConnectionState.done) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (context.mounted) {
showedError.value = false;
}
});

return loading;
}

if (errorText.contains(debugErrorTag)) {
return Text(errorText);
// Received some valid data which isn't an error (proceed normally, call builder)
if (data != null && !data.isError) {
return builder(context, data.value as T);
}

final dialog = ErrorAlert(error: error);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
showDialog(context: context, builder: dialog.build);
});
// Future error ed or received data which is an error
if (snapshot.hasError || (data != null && data.isError)) {
final error = snapshot.error ?? data!.error!;

return fallbackBuilder(context, error);
},
loading: () {
return const Center(
child: LogoProgressIndicator(),
);
final errorText = error.toString();

if (errorText.contains(debugErrorTag)) {
return Text(errorText);
}

if (!showedError.value) {
final isTimeout = errorText.contains(timeoutErrorTag);

WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (context.mounted) {
showedError.value = true;
}

final dialog = isTimeout
? const OfflineAlert()
: ErrorAlert(
error: error,
);

showDialog(context: context, builder: (context) => dialog);
});
}

// Use the fallback builder to display alternative error widget
return fallbackBuilder(context, error);
}

// Should never reach here, display loading just in case
return loading;
},
);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/views/components/async/error_refresh_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class ErrorRefreshPage extends StatelessWidget {
final void Function() onRefresh;

static const refreshButtonKey = Key("refreshButton");
static const errorText = "An error occurred";
static const errorText = "An error occurred.";
static const refreshText = "Refresh";

const ErrorRefreshPage({
Expand Down
2 changes: 1 addition & 1 deletion lib/views/components/async/loading_icon_button.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:proxima/views/helpers/types.dart";
import "package:proxima/views/helpers/types/future_void_callback.dart";

enum LoadingState {
still,
Expand Down
30 changes: 30 additions & 0 deletions lib/views/components/async/offline_alert.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import "package:flutter/material.dart";

/// A simple alert dialog that displays the offline error message.
/// Used by the circular value when the future times out.
class OfflineAlert extends StatelessWidget {
static const okButtonKey = Key("offlineAlertOkButton");

static const errorMessage =
"Your device is offline or has poor internet connectivity.\nPlease try again later.";

/// Constructor for the [OfflineAlert] widget.
const OfflineAlert({
super.key,
});

@override
Widget build(BuildContext context) {
final okButton = TextButton(
onPressed: Navigator.of(context).pop,
key: okButtonKey,
child: const Text("OK"),
);

return AlertDialog(
title: const Text("Device offline"),
content: const Text(errorMessage),
actions: [okButton],
);
}
}
File renamed without changes.
30 changes: 30 additions & 0 deletions lib/views/helpers/types/result.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/// Wrapper class to represent the result of a computation.
/// It can take the form of a value [Result.value]
/// or the form of an error [Result.error].
/// We use it to wrap a future instead of throwing an exception.
/// Useful to wrap futures coming from a provider.
class Result<V, E> {
final V? value;
final E? error;

Result._(this.value, this.error);

factory Result.value(V value) {
return Result._(value, null);
}

factory Result.error(E error) {
return Result._(null, error);
}

bool get isError => error != null;
}

/// Extention on [Future] to automatically wrap them in a [Result]
/// instead of throwing an error.
extension MapFutureRes<T> on Future<T> {
Future<Result<T, Object?>> mapRes() {
return then((value) => Result.value(value))
.onError((error, stackTrace) => Result<T, Object?>.error(error));
}
}
6 changes: 4 additions & 2 deletions lib/views/pages/create_account/create_account_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "package:proxima/services/authentication/auth_login_service.dart";
import "package:proxima/viewmodels/create_account_view_model.dart";
import "package:proxima/viewmodels/login_view_model.dart";
import "package:proxima/views/components/async/circular_value.dart";
import "package:proxima/views/helpers/types/result.dart";
import "package:proxima/views/navigation/leading_back_button/leading_back_button.dart";
import "package:proxima/views/navigation/routes.dart";
import "package:proxima/views/pages/create_account/create_account_form.dart";
Expand All @@ -15,7 +16,8 @@ class CreateAccountPage extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncErrors = ref.watch(createAccountViewModelProvider);
final asyncErrors =
ref.watch(createAccountViewModelProvider.future).mapRes();

navigateToLoginPageOnLogout(context, ref);

Expand All @@ -27,7 +29,7 @@ class CreateAccountPage extends ConsumerWidget {

final form = Center(
child: CircularValue(
value: asyncErrors,
future: asyncErrors,
builder: (context, errors) => CreateAccountForm(
validation: errors,
),
Expand Down
6 changes: 4 additions & 2 deletions lib/views/pages/home/content/challenge/challenge_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:proxima/viewmodels/challenge_view_model.dart";
import "package:proxima/views/components/async/circular_value.dart";
import "package:proxima/views/components/async/error_refresh_page.dart";
import "package:proxima/views/helpers/types/result.dart";
import "package:proxima/views/pages/home/content/challenge/challenge_card.dart";

class ChallengeList extends ConsumerWidget {
const ChallengeList({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncChallenges = ref.watch(challengeViewModelProvider);
final asyncChallenges =
ref.watch(challengeViewModelProvider.future).mapRes();

const emptyChallenge = Center(
child: Text("No challenge available here!"),
);

final onRefresh = ref.read(challengeViewModelProvider.notifier).refresh;
return CircularValue(
value: asyncChallenges,
future: asyncChallenges,
builder: (context, challenges) {
return RefreshIndicator(
onRefresh: onRefresh,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "package:flutter/material.dart";
import "package:proxima/models/ui/post_details.dart";
import "package:proxima/views/helpers/types.dart";
import "package:proxima/views/helpers/types/future_void_callback.dart";
import "package:proxima/views/pages/home/content/feed/components/post_card.dart";

class PostList extends StatelessWidget {
Expand Down
5 changes: 3 additions & 2 deletions lib/views/pages/home/content/feed/post_feed.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "package:proxima/viewmodels/posts_feed_view_model.dart";
import "package:proxima/views/components/async/circular_value.dart";
import "package:proxima/views/components/async/error_refresh_page.dart";
import "package:proxima/views/components/options/feed/feed_sort_option_chips.dart";
import "package:proxima/views/helpers/types/result.dart";
import "package:proxima/views/navigation/routes.dart";
import "package:proxima/views/pages/home/content/feed/components/post_list.dart";

Expand All @@ -21,7 +22,7 @@ class PostFeed extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncPosts = ref.watch(postsFeedViewModelProvider);
final asyncPosts = ref.watch(postsFeedViewModelProvider.future).mapRes();

final newPostButton = InkWell(
onTap: () {
Expand Down Expand Up @@ -67,7 +68,7 @@ class PostFeed extends ConsumerWidget {
const Divider(),
Expanded(
child: CircularValue(
value: asyncPosts,
future: asyncPosts,
builder: (context, posts) {
final postsList = PostList(
posts: posts,
Expand Down
5 changes: 3 additions & 2 deletions lib/views/pages/home/content/map/map_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "package:proxima/viewmodels/map/map_view_model.dart";
import "package:proxima/views/components/async/circular_value.dart";
import "package:proxima/views/components/async/error_refresh_page.dart";
import "package:proxima/views/components/options/map/map_selection_option_chips.dart";
import "package:proxima/views/helpers/types/result.dart";
import "package:proxima/views/pages/home/content/map/components/post_map.dart";

/// This widget displays a map with chips to select the type of map.
Expand All @@ -16,10 +17,10 @@ class MapScreen extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final mapInfo = ref.watch(mapViewModelProvider);
final mapInfo = ref.watch(mapViewModelProvider.future).mapRes();

return CircularValue(
value: mapInfo,
future: mapInfo,
builder: (context, value) {
return Scaffold(
key: mapScreenKey,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "package:flutter/material.dart";
import "package:proxima/models/ui/ranking/ranking_details.dart";
import "package:proxima/views/helpers/types.dart";
import "package:proxima/views/helpers/types/future_void_callback.dart";
import "package:proxima/views/pages/home/content/ranking/components/ranking_card.dart";

/// A widget that displays a list of ranking cards.
Expand Down
5 changes: 3 additions & 2 deletions lib/views/pages/home/content/ranking/ranking_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:proxima/viewmodels/users_ranking_view_model.dart";
import "package:proxima/views/components/async/circular_value.dart";
import "package:proxima/views/helpers/types/result.dart";
import "package:proxima/views/pages/home/content/ranking/components/ranking_widget.dart";

/// The Ranking page home content that is accessible via the bottom
Expand All @@ -12,10 +13,10 @@ class RankingPage extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final ranking = ref.watch(usersRankingViewModelProvider);
final ranking = ref.watch(usersRankingViewModelProvider.future).mapRes();

return CircularValue(
value: ranking,
future: ranking,
builder: (context, value) => RankingWidget(ranking: value),
);
}
Expand Down
Loading

0 comments on commit 56aac34

Please sign in to comment.