diff --git a/packages/example/lib/another_component.dart b/packages/example/lib/another_component.dart index 93c1e54..f774f84 100644 --- a/packages/example/lib/another_component.dart +++ b/packages/example/lib/another_component.dart @@ -7,7 +7,11 @@ class AnotherComponent extends StatelessWidget { @override Widget build(BuildContext context) { final lol = QueryBowl.of(context).getQuery("greetings"); + final deadQuery = + QueryBowl.of(context).getQuery("external_data"); if (lol?.data == null) return const CircularProgressIndicator(); - return Text("${lol!.data!} from AnotherComponent"); + return Text( + "${lol!.data!} from AnotherComponent\nDeadQuery (It should be null after 10 seconds): ${deadQuery?.data}", + ); } } diff --git a/packages/example/lib/main.dart b/packages/example/lib/main.dart index 25934ca..b507bf3 100644 --- a/packages/example/lib/main.dart +++ b/packages/example/lib/main.dart @@ -30,15 +30,18 @@ class MyApp extends StatelessWidget { } final successJob = QueryJob( - queryKey: "greetings", - task: (queryKey, _) => Future.delayed(const Duration(seconds: 2), - () => "Welcome ($queryKey) ${Random.secure().nextInt(100)}")); + queryKey: "greetings", + task: (queryKey, _) => Future.delayed(const Duration(seconds: 2), + () => "Welcome ($queryKey) ${Random.secure().nextInt(100)}"), +); final failedJob = QueryJob( queryKey: "failure", task: (queryKey, _) => Random().nextBool() ? Future.error("[$queryKey] Failed for unknown reason") - : Future.value("Success, you'll get slowly ${Random().nextInt(100)}!"), + : Future.value( + "Success, you'll get slowly ${Random().nextInt(100)}!", + ), ); class MyHomePage extends StatefulWidget { @@ -97,7 +100,8 @@ class _MyHomePageState extends State { children: [ if (query.hasError) Text( - "${query.error}. Retrying: ${query.retryAttempts}"), + "${query.error}. Retrying: ${query.retryAttempts}", + ), if (query.hasData) Text( "Success after ${query.retryAttempts}. Data: ${query.data}"), diff --git a/packages/example/lib/query_with_external_data.dart b/packages/example/lib/query_with_external_data.dart index b0c6ad0..600893b 100644 --- a/packages/example/lib/query_with_external_data.dart +++ b/packages/example/lib/query_with_external_data.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; final jobWithExternalData = QueryJob( queryKey: "external_data", + cacheTime: const Duration(seconds: 10), task: (queryKey, data) { return Future.delayed(const Duration(milliseconds: 500), () => "Hello from $queryKey with $data"); diff --git a/packages/fl_query/lib/models/query_job.dart b/packages/fl_query/lib/models/query_job.dart index a4445ae..8066789 100644 --- a/packages/fl_query/lib/models/query_job.dart +++ b/packages/fl_query/lib/models/query_job.dart @@ -14,9 +14,8 @@ class QueryJob { // got from global options final Duration? staleTime; + final Duration? cacheTime; - final QueryListener? onData; - final QueryListener? onError; QueryJob({ required this.queryKey, required this.task, @@ -24,8 +23,7 @@ class QueryJob { this.retryDelay, this.initialData, this.staleTime, - this.onData, - this.onError, + this.cacheTime, this.enabled, }); } diff --git a/packages/fl_query/lib/query.dart b/packages/fl_query/lib/query.dart index ebf0087..94aad84 100644 --- a/packages/fl_query/lib/query.dart +++ b/packages/fl_query/lib/query.dart @@ -29,6 +29,7 @@ class Query extends ChangeNotifier { // got from global options final Duration _staleTime; + final Duration _cacheTime; // all properties T? data; @@ -54,10 +55,16 @@ class Query extends ChangeNotifier { Outside? _prevUsedExternalData; + /// used for keeping track of query activity. If the are no mounts & + /// the passed cached time is over than the query is removed from + /// storage/cache + Set _mounts = {}; + Query({ required this.queryKey, required this.task, required Duration staleTime, + required Duration cacheTime, required Outside externalData, required this.retries, required this.retryDelay, @@ -67,6 +74,7 @@ class Query extends ChangeNotifier { QueryListener? onError, }) : status = QueryStatus.pending, _staleTime = staleTime, + _cacheTime = cacheTime, _initialData = initialData, _externalData = externalData, data = initialData, @@ -74,18 +82,22 @@ class Query extends ChangeNotifier { _onError = onError, updatedAt = DateTime.now(); - Query.fromOptions(QueryJob options, - {required Outside externalData}) - : queryKey = options.queryKey, + Query.fromOptions( + QueryJob options, { + required Outside externalData, + QueryListener? onData, + QueryListener? onError, + }) : queryKey = options.queryKey, enabled = options.enabled ?? true, task = options.task, retries = options.retries ?? 3, retryDelay = options.retryDelay ?? const Duration(milliseconds: 200), - _staleTime = options.staleTime ?? const Duration(days: 1), + _staleTime = options.staleTime ?? const Duration(milliseconds: 500), + _cacheTime = options.cacheTime ?? const Duration(minutes: 5), _initialData = options.initialData, _externalData = externalData, - _onData = options.onData, - _onError = options.onError, + _onData = onData, + _onError = onError, data = options.initialData, status = QueryStatus.pending, updatedAt = DateTime.now(); @@ -100,11 +112,29 @@ class Query extends ChangeNotifier { status == QueryStatus.refetching && (data != null || error != null); bool get isSucceeded => status == QueryStatus.succeed && data != null; bool get isIdle => isSucceeded && error == null; + bool get isInactive => _mounts.isEmpty; Outside get externalData => _externalData; Outside? get prevUsedExternalData => _prevUsedExternalData; // all methods + void mount(Widget widget) { + _mounts.add(widget); + } + + void unmount(Widget widget) { + if (_mounts.length == 1) { + Future.delayed(_cacheTime, () { + _mounts.remove(widget); + // for letting know QueryBowl that this one's time has come for + // getting crushed + notifyListeners(); + }); + } else { + _mounts.remove(widget); + } + } + /// Calls the task function & doesn't check if there's already /// cached data available Future _execute() async { @@ -213,4 +243,11 @@ class Query extends ChangeNotifier { } A? cast() => this is A ? this as A : null; + + String get debugLabel => "Query($queryKey)"; + + @override + String toString() { + return debugLabel; + } } diff --git a/packages/fl_query/lib/query_bowl.dart b/packages/fl_query/lib/query_bowl.dart index 348f2c6..cce7e71 100644 --- a/packages/fl_query/lib/query_bowl.dart +++ b/packages/fl_query/lib/query_bowl.dart @@ -8,14 +8,16 @@ import 'package:flutter/widgets.dart'; class QueryBowlScope extends StatefulWidget { final Widget child; final Duration staleTime; + final Duration cacheTime; /// used for periodically checking if any query got stale. /// If none is supplied then half of the value of staleTime is used - final Duration? refreshInterval; + final Duration refreshInterval; const QueryBowlScope({ required this.child, - this.staleTime = const Duration(minutes: 5), - this.refreshInterval, + this.staleTime = const Duration(milliseconds: 500), + this.cacheTime = const Duration(minutes: 5), + this.refreshInterval = const Duration(minutes: 5), Key? key, }) : super(key: key); @@ -33,10 +35,7 @@ class _QueryBowlScopeState extends State { super.initState(); queries = {}; refreshIntervalTimer = Timer.periodic( - widget.refreshInterval ?? - Duration( - milliseconds: (widget.staleTime.inMilliseconds / 2).round(), - ), + widget.refreshInterval, _checkAndUpdateStaleQueriesOnBg, ); } @@ -58,19 +57,25 @@ class _QueryBowlScopeState extends State { void _listenToQueryUpdate() { for (final query in queries) { - query.addListener(updateQueries); + query.addListener(() => updateQueries(query)); } } void _disposeListeners() { for (final query in queries) { - query.removeListener(updateQueries); + query.removeListener(() => updateQueries(query)); } } - void updateQueries() { + void updateQueries(Query query) { setState(() { - queries = Set.from(queries); + // checking & not including inactive queries + // basically garbage collecting queries + queries = Set.from( + query.isInactive + ? queries.where((el) => el.queryKey != query.queryKey) + : queries, + ); }); } @@ -114,8 +119,13 @@ class QueryBowl extends InheritedWidget { _queries = queries, super(child: child, key: key); - Future fetchQuery(QueryJob options, - {required Outside externalData}) async { + Future fetchQuery( + QueryJob options, { + required Outside externalData, + final QueryListener? onData, + final QueryListener? onError, + Widget? mount, + }) async { final prevQuery = _queries.firstWhereOrNull((q) => q.queryKey == options.queryKey); if (prevQuery is Query) { @@ -123,16 +133,23 @@ class QueryBowl extends InheritedWidget { // changed final hasExternalDataChanged = prevQuery.prevUsedExternalData != externalData; + if (mount != null) prevQuery.mount(mount); if (!prevQuery.hasData || hasExternalDataChanged) { if (hasExternalDataChanged) prevQuery.setExternalData(externalData); return prevQuery.fetched ? await prevQuery.refetch() : await prevQuery.fetch(); } + // mounting the widget that is using the query in the prevQuery return prevQuery.data; } - final query = - Query.fromOptions(options, externalData: externalData); + final query = Query.fromOptions( + options, + externalData: externalData, + onData: onData, + onError: onError, + ); + if (mount != null) query.mount(mount); _addQuery(query); return await query.fetch(); } diff --git a/packages/fl_query/lib/query_builder.dart b/packages/fl_query/lib/query_builder.dart index 7b9a9fb..1a28809 100644 --- a/packages/fl_query/lib/query_builder.dart +++ b/packages/fl_query/lib/query_builder.dart @@ -8,10 +8,19 @@ class QueryBuilder extends StatefulWidget { final QueryJob job; final Outside externalData; + /// Called when the query returns new data, on query + /// refetch or query gets expired + final QueryListener? onData; + + /// Called when the query returns error + final QueryListener? onError; + const QueryBuilder({ required this.job, required this.externalData, required this.builder, + this.onData, + this.onError, Key? key, }) : super(key: key); @@ -22,28 +31,51 @@ class QueryBuilder extends StatefulWidget { class _QueryBuilderState extends State> { + late QueryBowl queryBowl; + @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { - await QueryBowl.of(context).fetchQuery(widget.job, - externalData: widget.externalData); + queryBowl = QueryBowl.of(context); + await queryBowl.fetchQuery( + widget.job, + externalData: widget.externalData, + onData: widget.onData, + onError: widget.onError, + mount: widget, + ); }); } @override void didUpdateWidget(covariant oldWidget) { if (oldWidget.externalData != widget.externalData) { - QueryBowl.of(context) - .fetchQuery(widget.job, externalData: widget.externalData); + queryBowl = QueryBowl.of(context); + // clearing up the old widget before adding the new updated one + // so unmounted zombie widgets don't get piled up + queryBowl.getQuery(widget.job.queryKey)?.unmount(oldWidget); + queryBowl.fetchQuery( + widget.job, + externalData: widget.externalData, + onData: widget.onData, + onError: widget.onError, + mount: widget, + ); } super.didUpdateWidget(oldWidget); } + @override + void dispose() { + queryBowl.getQuery(widget.job.queryKey)?.unmount(widget); + super.dispose(); + } + @override Widget build(BuildContext context) { - final query = - QueryBowl.of(context).getQuery(widget.job.queryKey); + queryBowl = QueryBowl.of(context); + final query = queryBowl.getQuery(widget.job.queryKey); if (query == null) return Container(); return widget.builder(context, query); }