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 extends BaseOperation 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 loadFromDisk() async { final box = await Hive.lazyBox(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 saveToDisk({bool delete = false}) async { if (!canCacheToDisk) return; if (!hasData) { if (delete) { final box = await Hive.lazyBox(kFlQueryBoxKey); await box.delete(queryKey); } return; } final box = await Hive.lazyBox(kFlQueryBoxKey); await box.put(queryKey, serialize(data!)!); } /// Calls the task function & doesn't check if there's already /// cached data available @protected Future 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 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 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 setData(); @protected void setError(dynamic); @protected FutureOr notifyDataListeners() async { for (final onData in onDataListeners) { await onData(data); } } @protected FutureOr 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 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); }