411 lines
12 KiB
Dart
411 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:fl_query/fl_query.dart';
|
|
import 'package:fl_query/src/base_operation.dart';
|
|
import 'package:fl_query/src/mixins/autocast.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:hive/hive.dart';
|
|
|
|
abstract class BaseQuery<T extends Object, Outside, Error>
|
|
extends BaseOperation<T, Error> with AutoCast {
|
|
// all params
|
|
final String queryKey;
|
|
bool? refetchOnMount;
|
|
bool? refetchOnReconnect;
|
|
|
|
final T? _initialData;
|
|
|
|
// got from global options
|
|
Duration _staleTime;
|
|
|
|
/// total count of how many times the query retried to get a successful
|
|
/// result
|
|
int refetchCount = 0;
|
|
bool enabled;
|
|
|
|
QueryStatus status;
|
|
|
|
@protected
|
|
final Set onDataListeners = Set();
|
|
@protected
|
|
final Set onErrorListeners = Set();
|
|
|
|
// externalData will always be passed to the task Callback
|
|
// it will change based on the presence of QueryBuilder
|
|
Outside _externalData;
|
|
|
|
Outside? _prevUsedExternalData;
|
|
|
|
Duration? refetchInterval;
|
|
|
|
Timer? _refetchIntervalTimer;
|
|
|
|
bool? refetchOnApplicationResume;
|
|
bool? refetchOnWindowFocus;
|
|
|
|
T? _previousData;
|
|
|
|
BaseQuery({
|
|
required this.queryKey,
|
|
required Duration staleTime,
|
|
required super.cacheTime,
|
|
required Outside externalData,
|
|
required super.retries,
|
|
required super.retryDelay,
|
|
required this.status,
|
|
super.connectivity,
|
|
this.refetchOnMount,
|
|
this.refetchOnReconnect,
|
|
this.refetchInterval,
|
|
this.refetchOnApplicationResume,
|
|
this.refetchOnWindowFocus,
|
|
this.enabled = true,
|
|
T? previousData,
|
|
T? initialData,
|
|
onData,
|
|
onError,
|
|
}) : _staleTime = staleTime,
|
|
_initialData = initialData,
|
|
_externalData = externalData,
|
|
_previousData = previousData,
|
|
super(data: previousData ?? initialData) {
|
|
if (onData != null) onDataListeners.add(onData);
|
|
if (onError != null) onErrorListeners.add(onError);
|
|
|
|
if (refetchInterval != null && refetchInterval != Duration.zero) {
|
|
_refetchIntervalTimer = createRefetchTimer();
|
|
}
|
|
if (canCacheToDisk) {
|
|
loadFromDisk();
|
|
}
|
|
}
|
|
// all getters & setters
|
|
|
|
Outside get externalData => _externalData;
|
|
Outside? get prevUsedExternalData => _prevUsedExternalData;
|
|
|
|
@protected
|
|
Timer createRefetchTimer();
|
|
|
|
@protected
|
|
Future<void> loadFromDisk() async {
|
|
final box = await Hive.lazyBox<String>(kFlQueryBoxKey);
|
|
final rawData = await box.get(queryKey);
|
|
if (rawData == null) return;
|
|
data = deserialize(rawData);
|
|
if (!isLoading && !isRefetching) {
|
|
status = QueryStatus.cached;
|
|
}
|
|
updatedAt = DateTime.now();
|
|
await notifyDataListeners();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> saveToDisk({bool delete = false}) async {
|
|
if (!canCacheToDisk) return;
|
|
if (!hasData) {
|
|
if (delete) {
|
|
final box = await Hive.lazyBox<String>(kFlQueryBoxKey);
|
|
await box.delete(queryKey);
|
|
}
|
|
return;
|
|
}
|
|
final box = await Hive.lazyBox<String>(kFlQueryBoxKey);
|
|
await box.put(queryKey, serialize(data!)!);
|
|
}
|
|
|
|
/// Calls the task function & doesn't check if there's already
|
|
/// cached data available
|
|
@protected
|
|
Future<void> execute() async {
|
|
try {
|
|
retryAttempts = 0;
|
|
await setData();
|
|
await saveToDisk();
|
|
_prevUsedExternalData = _externalData;
|
|
updatedAt = DateTime.now();
|
|
status = QueryStatus.success;
|
|
await notifyDataListeners();
|
|
notifyListeners();
|
|
} catch (e) {
|
|
if (retries == 0) {
|
|
status = QueryStatus.error;
|
|
setError(e);
|
|
await notifyErrorListeners();
|
|
notifyListeners();
|
|
} else {
|
|
// retrying for retry count if failed for the first time
|
|
while (retryAttempts <= retries) {
|
|
await Future.delayed(retryDelay);
|
|
try {
|
|
await setData();
|
|
await saveToDisk();
|
|
_prevUsedExternalData = _externalData;
|
|
status = QueryStatus.success;
|
|
await notifyDataListeners();
|
|
notifyListeners();
|
|
break;
|
|
} catch (e) {
|
|
if (retryAttempts == retries) {
|
|
status = QueryStatus.error;
|
|
setError(e);
|
|
await notifyErrorListeners();
|
|
notifyListeners();
|
|
break;
|
|
}
|
|
retryAttempts++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void addDataListener(listener) {
|
|
onDataListeners.add(listener);
|
|
}
|
|
|
|
void addErrorListener(listener) {
|
|
onErrorListeners.add(listener);
|
|
}
|
|
|
|
void removeDataListener(listener) {
|
|
onDataListeners.remove(listener);
|
|
}
|
|
|
|
void removeErrorListener(listener) {
|
|
onErrorListeners.remove(listener);
|
|
}
|
|
|
|
/// fetches data or runs the provided task initially
|
|
///
|
|
/// Once [data] is available it won't run the [task] ever again
|
|
/// and will only return the available data
|
|
///
|
|
/// If a [fetch] is already running in the background it'll just return
|
|
/// the current available [data] (which can be nul if no [initialPage]
|
|
/// was provided) instead of running the task to prevent race conditions
|
|
Future<T?> fetch() async {
|
|
if (!enabled) return null;
|
|
|
|
if (isCachedData) {
|
|
status = QueryStatus.loading;
|
|
await execute().then((_) {
|
|
fetched = true;
|
|
});
|
|
notifyListeners();
|
|
return data;
|
|
}
|
|
final x = hasData && !isPreviousData;
|
|
var isOnline = await isNetworkOnline;
|
|
|
|
/// if isLoading/isRefetching is true that means its already fetching/
|
|
/// refetching. So [_execute] again can create a race condition
|
|
if (isLoading || isRefetching || !isOnline || (hasData && !isPreviousData))
|
|
return data;
|
|
status = QueryStatus.loading;
|
|
notifyListeners();
|
|
return execute().then((_) {
|
|
fetched = true;
|
|
return data;
|
|
});
|
|
}
|
|
|
|
/// refetches a valid or invalid [Query]
|
|
///
|
|
/// When called before calling [fetch] in a [Query] it'll
|
|
/// automatically run [fetch]
|
|
///
|
|
/// But if it's used to fetch the first data of a non-enabled [Query]
|
|
/// aka `LazyQuery`, it'll execute the task & will set the status
|
|
/// `enabled=true`
|
|
///
|
|
/// If a [refetch] is already running in the background it'll just return
|
|
/// the current available [data] instead of running the task to prevent
|
|
/// race conditions
|
|
Future<T?> refetch() async {
|
|
/// if isLoading/isRefetching is true that means its already fetching/
|
|
/// refetching. So [_execute] again can create a race condition
|
|
if (isRefetching || isLoading || !(await isNetworkOnline)) return data;
|
|
if (enabled && !fetched) return await fetch();
|
|
status = QueryStatus.refetching;
|
|
refetchCount++;
|
|
// disabling the lazy query bound when query was actually called
|
|
if (!enabled) enabled = true;
|
|
notifyListeners();
|
|
return await execute().then((_) => data);
|
|
}
|
|
|
|
@protected
|
|
String? serialize(T data);
|
|
|
|
@protected
|
|
T? deserialize(String rawData);
|
|
|
|
@protected
|
|
bool get canCacheToDisk;
|
|
|
|
@protected
|
|
FutureOr<void> setData();
|
|
@protected
|
|
void setError(dynamic);
|
|
@protected
|
|
FutureOr<void> notifyDataListeners() async {
|
|
for (final onData in onDataListeners) {
|
|
await onData(data);
|
|
}
|
|
}
|
|
|
|
@protected
|
|
FutureOr<void> notifyErrorListeners() async {
|
|
for (final onError in onErrorListeners) {
|
|
await onError(error);
|
|
}
|
|
}
|
|
|
|
/// Sets the [externalData] from outside of the query
|
|
///
|
|
/// Remember, it's for the very instance of [Query]
|
|
/// So this won't persist through later UI/[Query] updates
|
|
void setExternalData(Outside externalData) {
|
|
_prevUsedExternalData = _externalData;
|
|
_externalData = externalData;
|
|
}
|
|
|
|
/// Resets the query
|
|
///
|
|
/// The values of internal state of the query are reset to the
|
|
/// initial ones
|
|
void reset() {
|
|
refetchCount = 0;
|
|
data = _previousData ?? _initialData;
|
|
saveToDisk(delete: true);
|
|
error = null;
|
|
fetched = false;
|
|
status = QueryStatus.idle;
|
|
retryAttempts = 0;
|
|
onDataListeners.clear();
|
|
onErrorListeners.clear();
|
|
mounts.clear();
|
|
}
|
|
|
|
@override
|
|
void dispose() async {
|
|
await Hive.openLazyBox(kFlQueryBoxKey).then((box) => box.delete(queryKey));
|
|
super.dispose();
|
|
}
|
|
|
|
/// Update configurations of the query
|
|
/// after already creating the Query instance
|
|
///
|
|
/// Remember, it's just for the single query instance
|
|
/// In the next UI update/render the options will get reset
|
|
/// to the default ones defined in the [QueryJob] or [QueryBowlScope]
|
|
void updateDefaultOptions({
|
|
Duration? refetchInterval,
|
|
Duration? staleTime,
|
|
Duration? cacheTime,
|
|
bool? refetchOnMount,
|
|
bool? refetchOnReconnect,
|
|
bool? refetchOnApplicationResume,
|
|
bool? refetchOnWindowFocus,
|
|
}) {
|
|
bool updated = false;
|
|
if (this.refetchInterval == null &&
|
|
refetchInterval != null &&
|
|
refetchInterval != Duration.zero) {
|
|
this.refetchInterval = refetchInterval;
|
|
_refetchIntervalTimer?.cancel();
|
|
_refetchIntervalTimer = createRefetchTimer();
|
|
updated = true;
|
|
}
|
|
if (this.cacheTime == Duration(minutes: 5) && cacheTime != null) {
|
|
this.cacheTime = cacheTime;
|
|
updated = true;
|
|
}
|
|
if (this._staleTime == const Duration(milliseconds: 500) &&
|
|
staleTime != null) {
|
|
this._staleTime = staleTime;
|
|
updated = true;
|
|
}
|
|
if (this.refetchOnMount == null && refetchOnMount != null) {
|
|
this.refetchOnMount = refetchOnMount;
|
|
updated = true;
|
|
}
|
|
if (this.refetchOnReconnect == null && refetchOnReconnect != null) {
|
|
this.refetchOnReconnect = refetchOnReconnect;
|
|
updated = true;
|
|
}
|
|
if (this.refetchOnApplicationResume == null &&
|
|
refetchOnApplicationResume != null) {
|
|
this.refetchOnApplicationResume = refetchOnApplicationResume;
|
|
updated = true;
|
|
}
|
|
if (this.refetchOnWindowFocus == null && refetchOnWindowFocus != null) {
|
|
this.refetchOnWindowFocus = refetchOnWindowFocus;
|
|
updated = true;
|
|
}
|
|
if (updated) notifyListeners();
|
|
}
|
|
|
|
/// can be used to update the data manually. Can be useful when used
|
|
/// together with mutations to perform optimistic updates or manual data
|
|
/// updates
|
|
/// For updating particular queries after a mutation using the
|
|
/// `QueryBowl.refetchQueries` is more appropriate. But this one can be
|
|
/// used when only 1 query needs get updated
|
|
///
|
|
/// Every time a new instance of data should be returned because of
|
|
/// immutability
|
|
void setQueryData(QueryUpdateFunction<T> updateFn) async {
|
|
final newData = await updateFn(data);
|
|
if (data == newData) return;
|
|
data = newData;
|
|
await saveToDisk();
|
|
status = QueryStatus.success;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// invalidates the query
|
|
///
|
|
/// Forcefully makes the query stale & expired which results in a refetch
|
|
/// when met conditions
|
|
void invalidate() {
|
|
/// subtracting [staleTime] from [updatedAt] as staleTime=Duration.zero
|
|
/// indicates the query must never become stale but subtracting the
|
|
/// [staleTime] will always revert the updatedAt time to the default
|
|
/// time whenever isStale is called
|
|
updatedAt = updatedAt.subtract(_staleTime);
|
|
notifyListeners();
|
|
}
|
|
|
|
bool get isStale {
|
|
/// when [_staleTime] is [Duration.zero], the query will always be
|
|
/// stale & will never refetch in the background. But can be inactive
|
|
/// if [mounts.length] become zero
|
|
if (_staleTime == Duration.zero) return false;
|
|
|
|
// when [DateTime.now()] is after [update_at + stale_time] it means
|
|
// the data has become stale
|
|
return DateTime.now().isAfter(updatedAt.add(_staleTime));
|
|
}
|
|
|
|
bool get isError => status == QueryStatus.error;
|
|
bool get isIdle => status == QueryStatus.idle;
|
|
bool get isLoading => status == QueryStatus.loading;
|
|
bool get isRefetching => status == QueryStatus.refetching;
|
|
bool get isSuccess => status == QueryStatus.success;
|
|
bool get isCachedData => status == QueryStatus.cached;
|
|
bool get isPreviousData {
|
|
return _previousData != null ? _previousData == data : false;
|
|
}
|
|
|
|
String get debugLabel;
|
|
|
|
@override
|
|
String toString() {
|
|
return debugLabel;
|
|
}
|
|
|
|
operator ==(other);
|
|
}
|