Skip to content

Commit 00a0d1c

Browse files
Merge pull request #55 from leancodepl/feat/LMG-356-request-state-mapping-refactor
[LMG-356] RequestState mapping reimagination
2 parents 7796c61 + ecba918 commit 00a0d1c

8 files changed

Lines changed: 370 additions & 53 deletions

File tree

packages/leancode_cubit_utils/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
* **BREAKING CHANGE**: Remove `map` and `isEmpty` methods from `RequestCubit`.
44
* **BREAKING CHANGE**: Remove `TData` generic parameter from `RequestCubit`.
5+
* Add `map` method to `RequestState`.
6+
* Add `onRefreshing` callback to `RequestCubitBuilder`.
7+
* **BREAKING CHANGE**: Rename `RequestRefreshState` to `RequestRefreshingState`
8+
* **BREAKING CHANGE**: Make `data` in `RequestRefreshingState` non-nullable.
9+
* **BREAKING CHANGE**: Add `data` to `RequestEmptyState`.
510

611
## 0.4.2
712

packages/leancode_cubit_utils/README.md

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,51 @@ The cubit itself handles the things like:
9696

9797
If you call `refresh()` on `ArgsRequestCubit` it will perform a request with the last used arguments. They are also available under `lastRequestArgs` field.
9898

99+
### `RequestState`
100+
101+
`RequestState` represents the state of a request and can be one of the following:
102+
- `RequestInitialState` - the request has not been started yet,
103+
- `RequestLoadingState` - the request is currently being performed,
104+
- `RequestSuccessState<TOut>` - the request completed successfully with data,
105+
- `RequestErrorState<TError>` - the request failed with an error,
106+
- `RequestRefreshingState<TOut>` - the request is being refreshed while previous data is still available,
107+
- `RequestEmptyState<TOut>` - the request completed successfully but returned empty data.
108+
109+
#### `map` method
110+
111+
`RequestState` provides a `map` method that allows you to transform the state into a value of any type. This is useful when you need to derive a value based on the current state without building a widget. The method accepts the following parameters:
112+
113+
- `T Function()? initial` - called when the request is in its initial state. If not provided, falls back to `loading`,
114+
- `T Function() loading` - called when the request is loading (required),
115+
- `T Function(TOut? data) success` - called when the request completed successfully (required),
116+
- `T Function(TError? err, Object? exception, StackTrace? st) error` - called when the request failed (required),
117+
- `T Function(TOut data)? refreshing` - called when the request is refreshing with previous data. If not provided, falls back to `success`,
118+
- `T Function(TOut? data)? empty` - called when the request completed successfully but returned empty data. If not provided, falls back to `success`.
119+
120+
Example usage:
121+
122+
```dart
123+
Scaffold(
124+
appBar: state.map<AppBar>(
125+
loading: () => const LoadingAppBar(),
126+
success: (data) => SuccessAppBar(data: data),
127+
error: (err, exception, st) => const ErrorAppBar(error: err),
128+
),
129+
body: YourPageContent(),
130+
)
131+
```
132+
99133
### `RequestCubitBuilder`
100134

101-
`RequestCubitBuilder` is a widget that builds a widget based on the current state of `BaseRequestCubit`. It takes a numerous builders for each state:
102-
- `WidgetBuilder? onInitial` - use it to show a widget before invoking the request for the first time,
135+
`RequestCubitBuilder` is a widget that builds a widget based on the current state of `BaseRequestCubit`. It takes numerous builders for each state:
136+
- `WidgetBuilder? onInitial` - use it to show a widget before invoking the request for the first time.
103137
- `WidgetBuilder? onLoading` - use it to show a loader widget while the request is being performed,
104138
- `WidgetBuilder? onError` - use it to show error widget when processing the request fails,
105-
- `RequestWidgetBuilder<TOut> onSuccess` - use it to build a page when the data is successfully loaded.
139+
- `RequestWidgetBuilder<TOut> onSuccess` - use it to build a page when the data is successfully loaded (required),
140+
- `WidgetBuilder? onEmpty` - use it to show a widget when the request returns empty data. If not provided, falls back to global config or empty widget,
141+
- `RequestWidgetBuilder<TOut>? onRefreshing` - use it to show a widget while refreshing with previous data still available. If not provided, falls back to `onSuccess`.
106142

107-
Other than builders, you also need to provide the cubit based on which the `RequestCubitBuilder` will be rebuilt. And you can also pass `onErrorCallback` which allows you to pass a callback to error widget builder. You may want to use it to implement retry button.
143+
Other than builders, you also need to provide the cubit based on which the `RequestCubitBuilder` will be rebuilt. You can also pass `onErrorCallback` which allows you to pass a callback to error widget builder. You may want to use it to implement a retry button.
108144

109145
Example usage of `RequestCubitBuilder`:
110146
```dart
@@ -135,10 +171,31 @@ RequestCubitBuilder(
135171
},
136172
);
137173
},
174+
onRefreshing: (context, data) {
175+
return Stack(
176+
children: [
177+
ListView.builder(
178+
itemCount: data.assignments.length,
179+
itemBuilder: (context, index) {
180+
final assignment = data.assignments[index];
181+
return ListTile(
182+
title: AppText(assignment.id),
183+
);
184+
},
185+
),
186+
const Positioned(
187+
top: 0,
188+
left: 0,
189+
right: 0,
190+
child: LinearProgressIndicator(),
191+
),
192+
],
193+
);
194+
},
138195
)
139196
```
140197

141-
As you may see `onInitial`, `onLoading` and `onError` are marked as optional parameter. In many projects each of those widgets are the same for each page. So in order to eliminate even more boilerplate code, instead of passing them all each time you want to use `RequestCubitBuilder`, you can define them globally and provide in the whole app using [`RequestLayoutConfigProvider`](#requestlayoutconfigprovider).
198+
As you may see `onLoading`, `onEmpty` and `onError` are marked as optional parameters. In many projects each of those widgets are the same for each page. So in order to eliminate even more boilerplate code, instead of passing them all each time you want to use `RequestCubitBuilder`, you can define them globally and provide in the whole app using [`RequestLayoutConfigProvider`](#requestlayoutconfigprovider).
142199

143200
### `RequestLayoutConfigProvider`
144201

packages/leancode_cubit_utils/example/lib/pages/request/request_page.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ class RequestPage extends StatelessWidget {
4141
Center(
4242
child: RequestCubitBuilder(
4343
cubit: context.read<UserRequestCubit>(),
44-
onSuccess: (context, data) =>
45-
Text('${data.name} ${data.surname}'),
44+
onSuccess: (context, data) => Text(
45+
'${data.name} ${data.surname}',
46+
),
4647
),
4748
),
4849
const SizedBox(height: 16),

packages/leancode_cubit_utils/lib/src/request/request_cubit.dart

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,11 @@ abstract class BaseRequestCubit<TRes, TOut, TError>
6767
}
6868
}
6969

70-
if (isRefresh) {
70+
if (state
71+
case RequestSuccessState(:final data) ||
72+
RequestRefreshingState(:final data) when isRefresh) {
7173
logger.info('Refreshing request.');
72-
emit(
73-
RequestRefreshState(switch (state) {
74-
RequestSuccessState(:final data) => data,
75-
RequestRefreshState(:final data) => data,
76-
_ => null,
77-
}),
78-
);
74+
emit(RequestRefreshingState(data));
7975
} else {
8076
logger.info('Request started.');
8177
emit(RequestLoadingState());
@@ -170,7 +166,50 @@ abstract class ArgsRequestCubit<TArgs, TRes, TOut, TError>
170166
}
171167

172168
/// Represents the state of a request.
173-
sealed class RequestState<TOut, TError> with EquatableMixin {}
169+
sealed class RequestState<TOut, TError> with EquatableMixin {
170+
/// Maps the current request state to a value of type [T].
171+
///
172+
/// * [initial] - returns a [T] value when the request is in
173+
/// its initial state (not yet started). **If not provided, falls back to
174+
/// [loading]**.
175+
/// * [loading] - returns a [T] value when the request is loading.
176+
/// * [success] - returns a [T] value when the request completed
177+
/// successfully with data.
178+
/// * [error] - returns a [T] value when the request failed with an error.
179+
/// * [refreshing] - returns a [T] value when the request is refreshing with
180+
/// previous data still available. **If not provided, falls back to
181+
/// [success].**
182+
/// * [empty] - returns a [T] value when the request completed successfully
183+
/// but returned empty data. **If not provided, falls back to [success].**
184+
///
185+
/// ## Example
186+
///
187+
/// ```dart
188+
/// Scaffold(
189+
/// appBar: state.map<AppBar>(
190+
/// onLoading: () => const LoadingAppBar(),
191+
/// onSuccess: (data) => SuccessAppBar(data: data),
192+
/// onError: (err, exception, st) => const ErrorAppBar(error: err),
193+
/// ),
194+
/// );
195+
/// ```
196+
T map<T>({
197+
T Function()? initial,
198+
required T Function() loading,
199+
required T Function(TOut data) success,
200+
required T Function(TError? err, Object? exception, StackTrace? st) error,
201+
T Function(TOut data)? refreshing,
202+
T Function(TOut data)? empty,
203+
}) => switch (this) {
204+
RequestInitialState() => (initial ?? loading)(),
205+
RequestLoadingState() => loading(),
206+
RequestSuccessState(:final data) => success(data),
207+
RequestErrorState(error: final err, :final exception, :final stackTrace) =>
208+
error(err, exception, stackTrace),
209+
RequestRefreshingState(:final data) => (refreshing ?? success)(data),
210+
RequestEmptyState(:final data) => (empty ?? success)(data),
211+
};
212+
}
174213

175214
/// Represents the initial state of a request.
176215
final class RequestInitialState<TOut, TError>
@@ -190,13 +229,13 @@ final class RequestLoadingState<TOut, TError>
190229
}
191230

192231
/// Represents the refresh state of a request.
193-
final class RequestRefreshState<TOut, TError>
232+
final class RequestRefreshingState<TOut, TError>
194233
extends RequestState<TOut, TError> {
195-
/// Creates a new [RequestRefreshState] with the previous [data].
196-
RequestRefreshState([this.data]);
234+
/// Creates a new [RequestRefreshingState] with the previous [data].
235+
RequestRefreshingState(this.data);
197236

198237
/// The previous data.
199-
final TOut? data;
238+
final TOut data;
200239

201240
@override
202241
List<Object?> get props => [data];
@@ -215,13 +254,17 @@ final class RequestSuccessState<TOut, TError>
215254
List<Object?> get props => [data];
216255
}
217256

218-
/// Represents a successful request with empty data.
257+
/// Represents a successful request with empty data. [TOut] has to be nullable
258+
/// if you cannot provide any meaningful data for the empty state.
219259
final class RequestEmptyState<TOut, TError> extends RequestState<TOut, TError> {
220-
/// Creates a new [RequestEmptyState]..
221-
RequestEmptyState();
260+
/// Creates a new [RequestEmptyState].
261+
RequestEmptyState(this.data);
262+
263+
/// The data returned by the request.
264+
final TOut data;
222265

223266
@override
224-
List<Object?> get props => [];
267+
List<Object?> get props => [data];
225268
}
226269

227270
/// Represents a failed request.

packages/leancode_cubit_utils/lib/src/request/request_cubit_builder.dart

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class RequestCubitBuilder<TOut, TError> extends StatelessWidget {
2626
this.onInitial,
2727
this.onLoading,
2828
this.onEmpty,
29+
this.onRefreshing,
2930
this.onError,
3031
this.onErrorCallback,
3132
});
@@ -45,6 +46,9 @@ class RequestCubitBuilder<TOut, TError> extends StatelessWidget {
4546
/// The builder that creates a widget when request returns empty data.
4647
final WidgetBuilder? onEmpty;
4748

49+
/// The builder that creates a widget when request is refreshing.
50+
final RequestWidgetBuilder<TOut>? onRefreshing;
51+
4852
/// The builder that creates a widget when request failed.
4953
final RequestErrorBuilder<TError>? onError;
5054

@@ -55,37 +59,37 @@ class RequestCubitBuilder<TOut, TError> extends StatelessWidget {
5559
Widget build(BuildContext context) {
5660
final config = context.read<RequestLayoutConfig>();
5761

62+
final effectiveOnLoading = onLoading ?? config.onLoading;
63+
final effectiveOnEmpty = onEmpty ?? config.onEmpty;
64+
final effectiveOnError = onError ?? config.onError;
65+
5866
return BlocBuilder<
5967
BaseRequestCubit<dynamic, TOut, TError>,
6068
RequestState<TOut, TError>
6169
>(
6270
bloc: cubit,
63-
builder: (context, state) {
64-
return switch (state) {
65-
RequestInitialState() =>
66-
onInitial?.call(context) ??
67-
onLoading?.call(context) ??
68-
config.onLoading(context),
69-
RequestLoadingState() =>
70-
onLoading?.call(context) ?? config.onLoading(context),
71-
RequestSuccessState(:final data) => onSuccess(context, data),
72-
RequestRefreshState(:final data) =>
73-
data != null
74-
? onSuccess(context, data)
75-
: onLoading?.call(context) ?? config.onLoading(context),
76-
RequestEmptyState() =>
77-
onEmpty?.call(context) ??
78-
config.onEmpty?.call(context) ??
79-
const SizedBox(),
80-
RequestErrorState() =>
81-
onError?.call(context, state, onErrorCallback ?? cubit.refresh) ??
82-
config.onError(
83-
context,
84-
state,
85-
onErrorCallback ?? cubit.refresh,
86-
),
87-
};
88-
},
71+
builder: (context, state) => state.map(
72+
initial: switch (onInitial) {
73+
final onInitial? => () => onInitial(context),
74+
_ => null,
75+
},
76+
loading: () => effectiveOnLoading(context),
77+
success: (data) => onSuccess(context, data),
78+
empty: switch (effectiveOnEmpty) {
79+
final onEmpty? => (_) => onEmpty(context),
80+
_ => null,
81+
},
82+
refreshing: switch (onRefreshing) {
83+
final onRefreshing? => (data) => onRefreshing(context, data),
84+
null => null,
85+
},
86+
error: (err, _, _) {
87+
final errorState = state as RequestErrorState<TOut, TError>;
88+
final callback = onErrorCallback ?? cubit.refresh;
89+
90+
return effectiveOnError(context, errorState, callback);
91+
},
92+
),
8993
);
9094
}
9195
}

packages/leancode_cubit_utils/test/request_cubit_builder_test.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,34 @@ import 'utils/http_status_codes.dart';
1010
import 'utils/mocked_http_client.dart';
1111
import 'utils/test_request_cubit.dart';
1212

13+
class FakeRequestCubit extends RequestCubit<http.Response, String, int>
14+
with RequestResultHandler<String> {
15+
FakeRequestCubit.success() : super('FakeRequestCubit') {
16+
emit(RequestSuccessState(fakeResult));
17+
}
18+
19+
FakeRequestCubit.error() : super('FakeRequestCubit') {
20+
emit(RequestErrorState(error: 1));
21+
}
22+
23+
FakeRequestCubit.empty() : super('FakeRequestCubit') {
24+
emit(RequestEmptyState(fakeResult));
25+
}
26+
27+
FakeRequestCubit.loading() : super('FakeRequestCubit') {
28+
emit(RequestLoadingState());
29+
}
30+
31+
FakeRequestCubit.refresh() : super('FakeRequestCubit') {
32+
emit(RequestRefreshingState(fakeResult));
33+
}
34+
35+
static const fakeResult = 'Result';
36+
37+
@override
38+
Future<http.Response> request() async => http.Response('', 1);
39+
}
40+
1341
class TestPage extends StatelessWidget {
1442
const TestPage({super.key, required this.child});
1543

@@ -158,5 +186,45 @@ void main() {
158186
expect(find.text('Success, data: Result'), findsOneWidget);
159187
await tester.pumpAndSettle();
160188
});
189+
190+
testWidgets('shows custom refresh widget when onRefreshing is provided', (
191+
tester,
192+
) async {
193+
final cubit = FakeRequestCubit.refresh();
194+
195+
await tester.pumpWidget(
196+
TestPage(
197+
child: RequestCubitBuilder(
198+
cubit: cubit,
199+
onRefreshing: (context, data) => const Text('Refreshing'),
200+
onSuccess: (context, data) => const Text('Success'),
201+
),
202+
),
203+
);
204+
await tester.pumpAndSettle();
205+
206+
expect(find.text('Refreshing'), findsOneWidget);
207+
});
208+
209+
testWidgets('onRefreshing not provided does not cause an error', (
210+
tester,
211+
) async {
212+
final cubit = FakeRequestCubit.refresh();
213+
214+
const key = Key('request_cubit_builder');
215+
216+
await tester.pumpWidget(
217+
TestPage(
218+
child: RequestCubitBuilder(
219+
key: key,
220+
cubit: cubit,
221+
onSuccess: (context, data) => const SizedBox(),
222+
),
223+
),
224+
);
225+
await tester.pumpAndSettle();
226+
227+
expect(find.byKey(key), findsOneWidget);
228+
});
161229
});
162230
}

0 commit comments

Comments
 (0)