Skip to content

Commit

Permalink
feat(cat-voices): add result builder with delayed loading (#1021)
Browse files Browse the repository at this point in the history
* feat(cat-voices): add result builder with delayed loading

* style: reformat

* chore: drop unused min delay

* Revert "chore: drop unused min delay"

This reverts commit 454c739.

* chore: cleanup

---------

Co-authored-by: Damian Moliński <[email protected]>
  • Loading branch information
dtscalac and damian-molinski authored Oct 17, 2024
1 parent 71340cb commit a330c53
Show file tree
Hide file tree
Showing 3 changed files with 351 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import 'package:catalyst_voices/pages/registration/wallet_link/bloc_wallet_link_builder.dart';
import 'package:catalyst_voices/pages/registration/widgets/registration_stage_message.dart';
import 'package:catalyst_voices/widgets/common/infrastructure/voices_result_builder.dart';
import 'package:catalyst_voices/widgets/widgets.dart';
import 'package:catalyst_voices_assets/catalyst_voices_assets.dart';
import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart';
Expand Down Expand Up @@ -114,15 +115,14 @@ class _Wallets extends StatelessWidget {

@override
Widget build(BuildContext context) {
return switch (result) {
Success(:final value) => value.isNotEmpty
? _WalletsList(wallets: value, onSelectWallet: onSelectWallet)
return ResultBuilder(
result: result,
successBuilder: (context, wallets) => wallets.isNotEmpty
? _WalletsList(wallets: wallets, onSelectWallet: onSelectWallet)
: _WalletsEmpty(onRetry: onRefreshTap),
Failure() => _WalletsError(onRetry: onRefreshTap),
_ => const Center(
child: DelayedWidget(child: VoicesCircularProgressIndicator()),
),
};
failureBuilder: (context, error) => _WalletsError(onRetry: onRefreshTap),
loadingBuilder: (context) => const _WalletsLoading(),
);
}
}

Expand Down Expand Up @@ -231,3 +231,14 @@ class _WalletsError extends StatelessWidget {
);
}
}

class _WalletsLoading extends StatelessWidget {
const _WalletsLoading();

@override
Widget build(BuildContext context) {
return const Center(
child: VoicesCircularProgressIndicator(),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import 'dart:async';

import 'package:catalyst_voices_shared/catalyst_voices_shared.dart';
import 'package:flutter/material.dart';
import 'package:result_type/result_type.dart';

/// A child builder that builds a widget depending on the [data] of type [T].
typedef ResultChildBuilder<T> = Widget Function(BuildContext context, T data);

/// A builder that builds different children depending
/// on the state of the [result].
///
/// If [result] is [Success] then [successBuilder] is used.
/// If [result] is [Failure] then [failureBuilder] is used.
/// If [Result] is `null` then [loadingBuilder] is used.
///
/// The builder implements a [minLoadingDuration] which will delay
/// [Success] or [Failure] state so that the [loadingBuilder] child is shown
/// no shorter than the [minLoadingDuration].
///
/// This prevents showing the loading state a split second before going
/// to the next state.
class ResultBuilder<S, F> extends StatefulWidget {
final Result<S, F>? result;
final ResultChildBuilder<S> successBuilder;
final ResultChildBuilder<F> failureBuilder;
final WidgetBuilder loadingBuilder;
final Duration minLoadingDuration;

const ResultBuilder({
super.key,
this.result,
required this.successBuilder,
required this.failureBuilder,
required this.loadingBuilder,
this.minLoadingDuration = const Duration(milliseconds: 300),
});

@override
State<ResultBuilder<S, F>> createState() => _ResultBuilderState<S, F>();
}

class _ResultBuilderState<S, F> extends State<ResultBuilder<S, F>> {
Result<S, F>? _result;
late DateTime _resultUpdatedAt;
Timer? _updateResultTimer;

@override
void initState() {
super.initState();

_updateResult(widget.result);
}

@override
void didUpdateWidget(ResultBuilder<S, F> oldWidget) {
super.didUpdateWidget(oldWidget);

_cancelResultUpdate();

if (_result == null && widget.result != null) {
if (_wasLoadingShownLongEnough()) {
_updateResult(widget.result);
} else {
_scheduleResultUpdate();
}
} else {
_updateResult(widget.result);
}
}

@override
void dispose() {
_updateResultTimer?.cancel();
super.dispose();
}

@override
Widget build(BuildContext context) {
return switch (_result) {
Success(:final value) => widget.successBuilder(context, value),
Failure(:final value) => widget.failureBuilder(context, value),
_ => widget.loadingBuilder(context),
};
}

bool _wasLoadingShownLongEnough() {
final now = DateTimeExt.now();
final duration = now.difference(_resultUpdatedAt);
return duration >= widget.minLoadingDuration;
}

void _scheduleResultUpdate() {
final now = DateTimeExt.now();
final duration = now.difference(_resultUpdatedAt);
if (duration >= widget.minLoadingDuration) {
_updateResult(widget.result);
} else {
_updateResultTimer = Timer(duration, () {
setState(() {
_updateResult(widget.result);
});
});
}
}

void _cancelResultUpdate() {
_updateResultTimer?.cancel();
_updateResultTimer = null;
}

void _updateResult(Result<S, F>? result) {
_result = result;
_resultUpdatedAt = DateTimeExt.now();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import 'dart:async';

import 'package:catalyst_voices/widgets/common/infrastructure/voices_result_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:result_type/result_type.dart';

import '../../../helpers/helpers.dart';

void main() {
group(ResultBuilder, () {
const minLoadingDuration = Duration(milliseconds: 300);

testWidgets('shows loading state when result is null',
(WidgetTester tester) async {
// Arrange: Provide a loading builder
await tester.pumpApp(
ResultBuilder<String, String>(
result: null,
successBuilder: (context, data) => Text('Success: $data'),
failureBuilder: (context, data) => Text('Failure: $data'),
loadingBuilder: (context) => const CircularProgressIndicator(),
),
);

// Let the application build
await tester.pump(const Duration(milliseconds: 100));

// Assert: Verify that the loading widget is shown
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});

testWidgets('shows success state when result is Success',
(WidgetTester tester) async {
// Arrange: Provide a success result
await tester.pumpApp(
ResultBuilder<String, String>(
result: Success('Test Success'),
successBuilder: (context, data) => Text('Success: $data'),
failureBuilder: (context, data) => Text('Failure: $data'),
loadingBuilder: (context) => const CircularProgressIndicator(),
),
);

// Let the application build
await tester.pumpAndSettle();

// Assert: Verify that the success widget is shown
expect(find.text('Success: Test Success'), findsOneWidget);
});

testWidgets('shows failure state when result is Failure',
(WidgetTester tester) async {
// Arrange: Provide a failure result
await tester.pumpApp(
ResultBuilder<String, String>(
result: Failure('Test Failure'),
successBuilder: (context, data) => Text('Success: $data'),
failureBuilder: (context, data) => Text('Failure: $data'),
loadingBuilder: (context) => const CircularProgressIndicator(),
),
);

// Let the application build
await tester.pumpAndSettle();

// Assert: Verify that the failure widget is shown
expect(find.text('Failure: Test Failure'), findsOneWidget);
});

testWidgets('shows loading state for the minimum duration',
(WidgetTester tester) async {
// Arrange: Provide a delayed success result

await tester.pumpApp(
ResultBuilder<String, String>(
result: null,
minLoadingDuration: minLoadingDuration,
successBuilder: (context, data) => Text('Success: $data'),
failureBuilder: (context, data) => Text('Failure: $data'),
loadingBuilder: (context) => const CircularProgressIndicator(),
),
);

// Let the application build
await tester.pump(const Duration(milliseconds: 100));

// Verify the initial loading state
expect(find.byType(CircularProgressIndicator), findsOneWidget);

// Simulate updating to success result after a delay
await tester.pump(minLoadingDuration); // Simulate passing of time

await tester.pumpApp(
ResultBuilder<String, String>(
result: Success('Test Success'),
minLoadingDuration: minLoadingDuration,
successBuilder: (context, data) => Text('Success: $data'),
failureBuilder: (context, data) => Text('Failure: $data'),
loadingBuilder: (context) => const CircularProgressIndicator(),
),
);

await tester.pumpAndSettle();

// Verify that the success widget is shown after minLoadingDuration
expect(find.text('Success: Test Success'), findsOneWidget);
});

testWidgets('does not update result before minLoadingDuration',
(WidgetTester tester) async {
// Arrange: Start with loading and transition to success
final completer = Completer<Result<String, String>>();

await tester.pumpApp(
FutureBuilder(
future: completer.future,
builder: (context, snapshot) {
return ResultBuilder<String, String>(
result: snapshot.data,
minLoadingDuration: minLoadingDuration,
successBuilder: (context, data) => Text('Success: $data'),
failureBuilder: (context, data) => Text('Failure: $data'),
loadingBuilder: (context) => const CircularProgressIndicator(),
);
},
),
);

// Let the application build
await tester.pump(const Duration(milliseconds: 100));

// Assert that the loading state is displayed
expect(find.byType(CircularProgressIndicator), findsOneWidget);

// Act: Before minLoadingDuration, success should not be displayed yet
await tester.pump(const Duration(milliseconds: 100)); // Simulate 100ms

// Assert that it is still showing the loading state
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Success: Test Success'), findsNothing);

// Simulate success
completer.complete(Success('Test Success'));

// Now simulate passing the minLoadingDuration,
// 100ms remaining at this point
await tester.pumpAndSettle(const Duration(milliseconds: 100));

// Verify that after the full duration, success is displayed
expect(find.text('Success: Test Success'), findsOneWidget);
});

testWidgets('handles result updates and switches between states',
(WidgetTester tester) async {
// Arrange: Start with a null result (loading state)
const successWidgetKey = Key('success');
const failureWidgetKey = Key('failure');

await tester.pumpApp(
StatefulBuilder(
builder: (context, setState) {
return ResultBuilder<String, String>(
result: null,
successBuilder: (context, data) =>
Text('Success: $data', key: successWidgetKey),
failureBuilder: (context, data) =>
Text('Failure: $data', key: failureWidgetKey),
loadingBuilder: (context) => const CircularProgressIndicator(),
);
},
),
);

// Let the application build
await tester.pump(const Duration(milliseconds: 100));

// Verify initial loading state
expect(find.byType(CircularProgressIndicator), findsOneWidget);

// Act: Update to success state
await tester.pumpApp(
ResultBuilder<String, String>(
result: Success('Test Success'),
successBuilder: (context, data) =>
Text('Success: $data', key: successWidgetKey),
failureBuilder: (context, data) =>
Text('Failure: $data', key: failureWidgetKey),
loadingBuilder: (context) => const CircularProgressIndicator(),
),
);
await tester.pumpAndSettle();

// Verify that success state is now shown
expect(find.byKey(successWidgetKey), findsOneWidget);
expect(find.text('Success: Test Success'), findsOneWidget);

// Act: Update to failure state
await tester.pumpApp(
ResultBuilder<String, String>(
result: Failure('Test Failure'),
successBuilder: (context, data) =>
Text('Success: $data', key: successWidgetKey),
failureBuilder: (context, data) =>
Text('Failure: $data', key: failureWidgetKey),
loadingBuilder: (context) => const CircularProgressIndicator(),
),
);
await tester.pumpAndSettle();

// Verify that failure state is now shown
expect(find.byKey(failureWidgetKey), findsOneWidget);
expect(find.text('Failure: Test Failure'), findsOneWidget);
});
});
}

0 comments on commit a330c53

Please sign in to comment.