-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cat-voices): add result builder with delayed loading (#1021)
* 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
1 parent
71340cb
commit a330c53
Showing
3 changed files
with
351 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
catalyst_voices/lib/widgets/common/infrastructure/voices_result_builder.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
216 changes: 216 additions & 0 deletions
216
catalyst_voices/test/widgets/common/infrastructure/voices_result_builder_test.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
} |