feat: add infinite query builder with example

fix(query): updateQueryFn refetch when stale and update state of builders when widget is mounted
This commit is contained in:
Kingkor Roy Tirtho
2023-02-21 12:03:45 +06:00
parent 975f9eafe1
commit efa2c81505
21 changed files with 778 additions and 143 deletions
@@ -8,9 +8,9 @@ abstract class DefaultConstants {
);
static const RefreshConfig refreshConfig = RefreshConfig(
staleDuration: Duration(seconds: 10),
staleDuration: Duration(minutes: 2, milliseconds: 250),
refreshInterval: Duration.zero,
refreshOnMount: true,
refreshOnMount: false,
refreshOnQueryFnChange: false,
);
@@ -4,6 +4,7 @@ import 'package:fl_query/src/collections/json_config.dart';
import 'package:fl_query/src/collections/refresh_config.dart';
import 'package:fl_query/src/collections/retry_config.dart';
import 'package:fl_query/src/core/cache.dart';
import 'package:fl_query/src/core/infinite_query.dart';
import 'package:fl_query/src/core/provider.dart';
import 'package:fl_query/src/core/query.dart';
import 'package:flutter/material.dart';
@@ -83,6 +84,98 @@ class QueryClient {
return await Future.wait(queries.map((query) => query.refresh()));
}
InfiniteQuery<DataType, ErrorType, KeyType, PageType>
createInfiniteQuery<DataType, ErrorType, KeyType, PageType>(
ValueKey<KeyType> key,
InfiniteQueryFn<DataType, PageType> queryFn, {
required InfiniteQueryNextPage<DataType, PageType> nextPage,
required PageType initialParam,
RetryConfig retryConfig = DefaultConstants.retryConfig,
RefreshConfig refreshConfig = DefaultConstants.refreshConfig,
JsonConfig<DataType>? jsonConfig,
}) {
final query = cache.infiniteQueries
.firstWhere(
(query) => query.key == key,
orElse: () => InfiniteQuery<DataType, ErrorType, KeyType, PageType>(
key,
queryFn,
nextPage: nextPage,
initialParam: initialParam,
retryConfig: retryConfig,
refreshConfig: refreshConfig,
jsonConfig: jsonConfig,
),
)
.cast<DataType, ErrorType, KeyType, PageType>();
query.updateQueryFn(queryFn);
cache.addInfiniteQuery(query);
return query;
}
Future<DataType?> fetchInfiniteQuery<DataType, ErrorType, KeyType, PageType>(
ValueKey<KeyType> key, InfiniteQueryFn<DataType, PageType> queryFn,
{required InfiniteQueryNextPage<DataType, PageType> nextPage,
required PageType initialParam,
RetryConfig retryConfig = DefaultConstants.retryConfig,
RefreshConfig refreshConfig = DefaultConstants.refreshConfig,
JsonConfig<DataType>? jsonConfig}) async {
final query = createInfiniteQuery<DataType, ErrorType, KeyType, PageType>(
key,
queryFn,
nextPage: nextPage,
initialParam: initialParam,
retryConfig: retryConfig,
refreshConfig: refreshConfig,
jsonConfig: jsonConfig,
);
return await query.fetch();
}
InfiniteQuery<DataType, ErrorType, KeyType, PageType>?
getInfiniteQuery<DataType, ErrorType, KeyType, PageType>(
ValueKey<KeyType> key) {
return cache.infiniteQueries
.firstWhereOrNull((query) => query.key == key)
?.cast<DataType, ErrorType, KeyType, PageType>();
}
List<InfiniteQuery> getInfiniteQueries(List<ValueKey> keys) {
return cache.infiniteQueries
.where((query) => keys.contains(query.key))
.toList();
}
Future<DataType?>
refreshInfiniteQuery<DataType, ErrorType, KeyType, PageType>(
ValueKey<KeyType> key,
[PageType? page]) async {
final query = getInfiniteQuery<DataType, ErrorType, KeyType, PageType>(key);
if (query == null) return null;
return await query.refresh(page);
}
Future<List<DataType>?>
refreshInfiniteQueryAllPages<DataType, ErrorType, KeyType, PageType>(
ValueKey<KeyType> key) async {
final query = getInfiniteQuery<DataType, ErrorType, KeyType, PageType>(key);
if (query == null) return [];
return await query.refreshAll();
}
Future<List> refreshInfiniteQueries(List<ValueKey> keys) async {
final queries = getInfiniteQueries(keys);
return await Future.wait(queries.map((query) => query.refresh()));
}
Future<Map<ValueKey, List?>> refreshInfiniteQueriesAllPages(
List<ValueKey> keys) async {
final queries = getInfiniteQueries(keys);
return await Future.wait(queries.map(
(query) async => MapEntry(query.key, await query.refreshAll())))
.then((qs) => Map.fromEntries(qs));
}
static QueryClient of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<QueryClientProvider>()!
@@ -64,17 +64,20 @@ class InfiniteQueryPage<DataType, ErrorType, PageType> {
class InfiniteQueryState<DataType, ErrorType, PageType> {
final Set<InfiniteQueryPage<DataType, ErrorType, PageType>> pages;
final InfiniteQueryFn<DataType, PageType> queryFn;
final InfiniteQueryNextPage<DataType, PageType> nextPage;
final InfiniteQueryNextPage<DataType, PageType> _nextPage;
const InfiniteQueryState({
required this.pages,
required this.queryFn,
required this.nextPage,
});
required InfiniteQueryNextPage<DataType, PageType> nextPage,
}) : _nextPage = nextPage;
PageType get lastPage => pages.last.page;
PageType? get getNextPage =>
nextPage(lastPage, pages.map((e) => e.data!).toList());
PageType? get getNextPage => _nextPage(
lastPage,
pages.map((e) => e.data).whereType<DataType>().toList(),
);
bool get hasNextPage => getNextPage != null;
InfiniteQueryState<DataType, ErrorType, PageType> copyWith({
@@ -85,11 +88,23 @@ class InfiniteQueryState<DataType, ErrorType, PageType> {
return InfiniteQueryState<DataType, ErrorType, PageType>(
pages: pages ?? this.pages,
queryFn: queryFn ?? this.queryFn,
nextPage: nextPage ?? this.nextPage,
nextPage: nextPage ?? this._nextPage,
);
}
}
class PageEvent<T, P> {
final P page;
final T data;
const PageEvent(this.page, this.data);
factory PageEvent.fromPage(
InfiniteQueryPage page,
) {
return PageEvent(page.page as P, page.data as T);
}
}
class InfiniteQuery<DataType, ErrorType, KeyType, PageType>
extends StateNotifier<InfiniteQueryState<DataType, ErrorType, PageType>>
with Retryer<DataType, ErrorType> {
@@ -106,7 +121,9 @@ class InfiniteQuery<DataType, ErrorType, KeyType, PageType>
this.retryConfig = DefaultConstants.retryConfig,
this.refreshConfig = DefaultConstants.refreshConfig,
this.jsonConfig,
}) : super(InfiniteQueryState<DataType, ErrorType, PageType>(
}) : _dataController = StreamController.broadcast(),
_errorController = StreamController.broadcast(),
super(InfiniteQueryState<DataType, ErrorType, PageType>(
pages: {
InfiniteQueryPage<DataType, ErrorType, PageType>(
page: initialParam,
@@ -119,17 +136,30 @@ class InfiniteQuery<DataType, ErrorType, KeyType, PageType>
)) {
if (jsonConfig != null) {
_mutex.protect(() async {
final json = await _box.get(key.toString());
final Map? json = await _box.get(key.value);
if (json != null) {
state = state.copyWith(
pages: json.map(
(key, value) => MapEntry(
key as PageType,
jsonConfig!.fromJson(value),
),
),
pages: json.entries
.map(
(entry) => InfiniteQueryPage<DataType, ErrorType, PageType>(
page: entry.key as PageType,
data: jsonConfig!.fromJson(
Map.castFrom<dynamic, dynamic, String, dynamic>(
entry.value,
)),
// this makes the page loaded from cache `stale`
updatedAt:
DateTime.now().subtract(refreshConfig.staleDuration),
staleDuration: refreshConfig.staleDuration,
),
)
.toSet(),
);
}
}).then((_) {
if (hasListeners) {
return fetch();
}
});
}
if (refreshConfig.refreshInterval > Duration.zero)
@@ -146,12 +176,18 @@ class InfiniteQuery<DataType, ErrorType, KeyType, PageType>
final _mutex = Mutex();
final _box = Hive.lazyBox("cache");
final StreamController<PageEvent<DataType, PageType>> _dataController;
final StreamController<PageEvent<ErrorType, PageType>> _errorController;
List<DataType> get pages =>
state.pages.map((e) => e.data).whereType<DataType>().toList();
List<ErrorType> get errors =>
state.pages.map((e) => e.error).whereType<ErrorType>().toList();
PageType get lastPage => state.lastPage;
Stream<PageEvent<DataType, PageType>> get dataStream =>
_dataController.stream;
Stream<PageEvent<ErrorType, PageType>> get errorStream =>
_errorController.stream;
bool get isLoadingPage => !hasPageData && !hasPageError && _mutex.isLocked;
bool get isRefreshingPage => (hasPageData || hasPageError) && _mutex.isLocked;
@@ -160,8 +196,8 @@ class InfiniteQuery<DataType, ErrorType, KeyType, PageType>
bool get hasPages => pages.isNotEmpty;
bool get hasErrors => errors.isNotEmpty;
bool get hasPageData => state.pages.last.data != null;
bool get hasPageError => state.pages.last.error != null;
bool get hasPageData => !hasPages ? false : state.pages.last.data != null;
bool get hasPageError => !hasPages ? false : state.pages.last.error != null;
bool get hasNextPage => state.hasNextPage;
@@ -171,51 +207,54 @@ class InfiniteQuery<DataType, ErrorType, KeyType, PageType>
() => state.queryFn(page),
config: retryConfig,
onSuccessful: (data) async {
final dataPage = state.pages
.firstWhere(
(e) => e.page == page,
orElse: () => InfiniteQueryPage<DataType, ErrorType, PageType>(
page: page,
updatedAt: DateTime.now(),
staleDuration: refreshConfig.staleDuration,
),
)
.copyWith(data: data);
state = state.copyWith(
pages: {
...state.pages,
state.pages
.firstWhere(
(e) => e.page == page,
orElse: () =>
InfiniteQueryPage<DataType, ErrorType, PageType>(
page: page,
updatedAt: DateTime.now(),
staleDuration: refreshConfig.staleDuration,
),
)
.copyWith(data: data),
},
pages: {...state.pages..remove(dataPage), dataPage},
);
if (dataPage.data != null)
_dataController.add(PageEvent.fromPage(dataPage));
if (jsonConfig != null) {
await _box.put(
key.toString(),
state.pages.map(
(e) => MapEntry(
e.page,
e.data != null ? jsonConfig!.toJson(e.data!) : null,
key.value,
Map.fromEntries(
state.pages.map(
(e) => MapEntry(
e.page,
e.data != null ? jsonConfig!.toJson(e.data!) : null,
),
),
),
);
}
},
onFailed: (error) {
final errorPage = state.pages
.firstWhere(
(e) => e.page == page,
orElse: () => InfiniteQueryPage<DataType, ErrorType, PageType>(
page: page,
updatedAt: DateTime.now(),
staleDuration: refreshConfig.staleDuration,
),
)
.copyWith(error: error);
state = state.copyWith(
pages: {
...state.pages,
state.pages
.firstWhere(
(e) => e.page == page,
orElse: () =>
InfiniteQueryPage<DataType, ErrorType, PageType>(
page: page,
updatedAt: DateTime.now(),
staleDuration: refreshConfig.staleDuration,
),
)
.copyWith(error: error),
...state.pages..remove(errorPage),
errorPage,
},
);
if (errorPage.error != null)
_errorController.add(PageEvent.fromPage(errorPage));
},
);
});
@@ -246,14 +285,54 @@ class InfiniteQuery<DataType, ErrorType, KeyType, PageType>
Future<DataType?> fetchNext() async {
final nextPage = state.getNextPage;
if (_mutex.isLocked || nextPage != null) {
return state.pages.firstWhereOrNull((e) => e.page == nextPage)?.data;
if (_mutex.isLocked || nextPage == null) {
return state.pages.lastOrNull?.data;
}
return await _operation(nextPage!).then((_) {
return await _operation(nextPage).then((_) {
return state.pages.firstWhereOrNull((e) => e.page == nextPage)?.data;
});
}
void updateQueryFn(InfiniteQueryFn<DataType, PageType> queryFn) {
if (state.queryFn == queryFn) return;
state = state.copyWith(queryFn: queryFn);
if (refreshConfig.refreshOnQueryFnChange) {
refreshAll();
} else {
Future.wait(
state.pages.map((page) async {
if (page.isStale) {
return await refresh(page.page);
}
}),
);
}
}
void updateNextPageFn(InfiniteQueryNextPage<DataType, PageType> nextPage) {
state = state.copyWith(nextPage: nextPage);
}
void setPageData(PageType page, DataType data) {
final newPage = state.pages
.firstWhere(
(e) => e.page == page,
orElse: () => InfiniteQueryPage<DataType, ErrorType, PageType>(
page: page,
updatedAt: DateTime.now(),
staleDuration: refreshConfig.staleDuration,
),
)
.copyWith(data: data);
state = state.copyWith(
pages: {
...state.pages..remove(newPage),
newPage,
},
);
}
@override
RemoveListener addListener(
Listener<InfiniteQueryState<DataType, ErrorType, PageType>> listener, {
@@ -276,4 +355,13 @@ class InfiniteQuery<DataType, ErrorType, KeyType, PageType>
@override
operator ==(Object other) =>
identical(this, other) || other is InfiniteQuery && key == other.key;
@override
int get hashCode => key.hashCode;
InfiniteQuery<NewDataType, NewErrorType, NewKeyType, NewPageType>
cast<NewDataType, NewErrorType, NewKeyType, NewPageType>() {
return this
as InfiniteQuery<NewDataType, NewErrorType, NewKeyType, NewPageType>;
}
}
+10 -9
View File
@@ -35,10 +35,11 @@ class QueryState<DataType, ErrorType> {
QueryState<DataType, ErrorType> copyWith({
DataType? data,
ErrorType? error,
DateTime? updatedAt,
QueryFn<DataType>? queryFn,
}) {
return QueryState<DataType, ErrorType>(
updatedAt: DateTime.now(),
updatedAt: updatedAt ?? this.updatedAt,
staleDuration: staleDuration,
data: data ?? this.data,
error: error ?? this.error,
@@ -80,7 +81,7 @@ class Query<DataType, ErrorType, KeyType>
_initial = jsonConfig!.fromJson(
Map.castFrom<dynamic, dynamic, String, dynamic>(json),
);
state = state.copyWith(data: _initial);
state = state.copyWith(data: _initial, updatedAt: DateTime.now());
}
}).then((_) {
if (hasListeners) {
@@ -124,7 +125,10 @@ class Query<DataType, ErrorType, KeyType>
state.queryFn,
config: retryConfig,
onSuccessful: (DataType? data) {
state = state.copyWith(data: data);
state = state.copyWith(
data: data,
updatedAt: DateTime.now(),
);
if (data != null) _dataController.add(data);
if (jsonConfig != null && data != null) {
_box.put(
@@ -134,7 +138,7 @@ class Query<DataType, ErrorType, KeyType>
}
},
onFailed: (ErrorType? error) {
state = state.copyWith(error: error);
state = state.copyWith(error: error, updatedAt: DateTime.now());
if (error != null) _errorController.add(error);
},
);
@@ -154,17 +158,14 @@ class Query<DataType, ErrorType, KeyType>
void updateQueryFn(QueryFn<DataType> queryFn) {
if (state.queryFn == queryFn) return;
// updatedAt is updated with copyWith so storing it
// here to check if the query is stale later
final stale = state.isStale;
state = state.copyWith(queryFn: queryFn);
if (stale || refreshConfig.refreshOnQueryFnChange) {
if (state.isStale || refreshConfig.refreshOnQueryFnChange) {
refresh();
}
}
void setData(DataType data) {
state = state.copyWith(data: data);
state = state.copyWith(data: data, updatedAt: DateTime.now());
}
@override
+15 -3
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:fl_query/src/collections/retry_config.dart';
import 'package:flutter/material.dart';
mixin Retryer<T, E> {
void retryOperation(
@@ -19,9 +20,20 @@ mixin Retryer<T, E> {
final result = await completer.future;
onSuccessful(result);
break;
} catch (e) {
if (attempts == config.maxRetries - 1 && e is E?) {
onFailed(e as E?);
} catch (e, stack) {
if (attempts == config.maxRetries - 1) {
if (e is E?) {
onFailed(e as E?);
} else {
FlutterError.reportError(
FlutterErrorDetails(
exception: e,
library: 'fl_query',
context: ErrorDescription('retryOperation'),
stack: stack,
),
);
}
}
}
}
@@ -0,0 +1,148 @@
import 'dart:async';
import 'package:fl_query/src/collections/default_configs.dart';
import 'package:fl_query/src/collections/json_config.dart';
import 'package:fl_query/src/collections/refresh_config.dart';
import 'package:fl_query/src/collections/retry_config.dart';
import 'package:fl_query/src/core/client.dart';
import 'package:fl_query/src/core/infinite_query.dart';
import 'package:flutter/material.dart';
typedef InfiniteQueryBuilderFn<DataType, ErrorType, KeyType, PageType> = Widget
Function(
BuildContext context,
InfiniteQuery<DataType, ErrorType, KeyType, PageType> query,
);
class InfiniteQueryBuilder<DataType, ErrorType, KeyType, PageType>
extends StatefulWidget {
final InfiniteQueryFn<DataType, PageType> queryFn;
final ValueKey<KeyType> queryKey;
final PageType initialPage;
final InfiniteQueryNextPage<DataType, PageType> nextPage;
final RetryConfig retryConfig;
final RefreshConfig refreshConfig;
final JsonConfig<DataType>? jsonConfig;
final ValueChanged<PageEvent<DataType, PageType>>? onData;
final ValueChanged<PageEvent<ErrorType, PageType>>? onError;
// widget specific
final bool enabled;
final InfiniteQueryBuilderFn<DataType, ErrorType, KeyType, PageType> builder;
const InfiniteQueryBuilder(
this.queryKey,
this.queryFn, {
required this.nextPage,
required this.builder,
required this.initialPage,
this.retryConfig = DefaultConstants.retryConfig,
this.refreshConfig = DefaultConstants.refreshConfig,
this.jsonConfig,
this.onData,
this.onError,
this.enabled = true,
super.key,
}) : assert(
(jsonConfig != null && enabled) || jsonConfig == null,
'jsonConfig is only supported when enabled is true',
);
@override
State<InfiniteQueryBuilder<DataType, ErrorType, KeyType, PageType>>
createState() =>
_InfiniteQueryBuilderState<DataType, ErrorType, KeyType, PageType>();
}
class _InfiniteQueryBuilderState<DataType, ErrorType, KeyType, PageType>
extends State<
InfiniteQueryBuilder<DataType, ErrorType, KeyType, PageType>> {
InfiniteQuery<DataType, ErrorType, KeyType, PageType>? query;
VoidCallback? removeListener;
StreamSubscription<PageEvent<DataType, PageType>>? dataSubscription;
StreamSubscription<PageEvent<ErrorType, PageType>>? errorSubscription;
void update(_) {
if (mounted) setState(() {});
}
Future<void> initialize() async {
setState(() {
query = QueryClient.of(context).createInfiniteQuery(
widget.queryKey,
widget.queryFn,
initialParam: widget.initialPage,
nextPage: widget.nextPage,
retryConfig: widget.retryConfig,
refreshConfig: widget.refreshConfig,
jsonConfig: widget.jsonConfig,
);
dataSubscription = query!.dataStream.listen(widget.onData);
errorSubscription = query!.errorStream.listen(widget.onError);
removeListener = query!.addListener(update);
});
if (widget.enabled) {
await query!.fetch();
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
initialize();
});
}
@override
void dispose() {
dataSubscription?.cancel();
errorSubscription?.cancel();
removeListener?.call();
super.dispose();
}
@override
void didUpdateWidget(oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.queryKey != widget.queryKey) {
dataSubscription?.cancel();
errorSubscription?.cancel();
removeListener?.call();
initialize();
return;
} else if (oldWidget.enabled != widget.enabled && widget.enabled) {
query!.fetch();
}
if (oldWidget.queryFn != widget.queryFn) {
query!.updateQueryFn(widget.queryFn);
}
if (oldWidget.nextPage != widget.nextPage) {
query!.updateNextPageFn(widget.nextPage);
}
if (oldWidget.onData != widget.onData) {
dataSubscription?.cancel();
dataSubscription = query!.dataStream.listen(widget.onData);
}
if (oldWidget.onError != widget.onError) {
errorSubscription?.cancel();
errorSubscription = query!.errorStream.listen(widget.onError);
}
}
@override
Widget build(BuildContext context) {
if (query == null) {
return const SizedBox.shrink();
}
return widget.builder(context, query!);
}
}
@@ -18,7 +18,6 @@ class QueryBuilder<DataType, ErrorType, KeyType> extends StatefulWidget {
final QueryFn<DataType> queryFn;
final ValueKey<KeyType> queryKey;
final QueryBuilderFn builder;
final DataType? initial;
final RetryConfig retryConfig;
@@ -30,6 +29,7 @@ class QueryBuilder<DataType, ErrorType, KeyType> extends StatefulWidget {
// widget specific
final bool enabled;
final QueryBuilderFn<DataType, ErrorType, KeyType> builder;
const QueryBuilder(
this.queryKey,
@@ -44,7 +44,7 @@ class QueryBuilder<DataType, ErrorType, KeyType> extends StatefulWidget {
this.enabled = true,
super.key,
}) : assert(
enabled && jsonConfig != null,
(jsonConfig != null && enabled) || jsonConfig == null,
'jsonConfig is only supported when enabled is true',
);
@@ -63,7 +63,7 @@ class _QueryBuilderState<DataType, ErrorType, KeyType>
StreamSubscription<ErrorType>? errorSubscription;
void update(_) {
setState(() {});
if (mounted) setState(() {});
}
Future<void> initialize() async {
@@ -115,6 +115,8 @@ class _QueryBuilderState<DataType, ErrorType, KeyType>
removeListener?.call();
initialize();
return;
} else if (oldWidget.enabled != widget.enabled && widget.enabled) {
query!.fetch();
}
if (oldWidget.queryFn != widget.queryFn) {
query!.updateQueryFn(widget.queryFn);
@@ -147,7 +149,8 @@ class _QueryBuilderState<DataType, ErrorType, KeyType>
DiagnosticsProperty<ValueKey<KeyType>>('queryKey', widget.queryKey),
);
properties.add(
DiagnosticsProperty<QueryBuilderFn>('builder', widget.builder),
DiagnosticsProperty<QueryBuilderFn<DataType, ErrorType, KeyType>>(
'builder', widget.builder),
);
properties.add(DiagnosticsProperty<DataType>('initial', widget.initial));
properties.add(
@@ -26,7 +26,7 @@ class _StateNotifierListenableState<T>
void initialize() {
removeListener = widget.notifier.addListener((_) {
setState(() {});
if (mounted) setState(() {});
});
}