diff --git a/packages/example/lib/another_component.dart b/packages/example/lib/another_component.dart new file mode 100644 index 0000000..24200e3 --- /dev/null +++ b/packages/example/lib/another_component.dart @@ -0,0 +1,13 @@ +import 'package:fl_query/query_bowl.dart'; +import 'package:flutter/material.dart'; + +class AnotherComponent extends StatelessWidget { + const AnotherComponent({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final lol = QueryBowl.of(context).getQuery("greetings"); + if (lol?.data == null) return const CircularProgressIndicator(); + return Text("${lol!.data!} from AnotherComponent"); + } +} diff --git a/packages/example/lib/main.dart b/packages/example/lib/main.dart index 3d0d5dc..a5403ba 100644 --- a/packages/example/lib/main.dart +++ b/packages/example/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:example/another_component.dart'; import 'package:fl_query/query_bowl.dart'; import 'package:fl_query/query_builder.dart'; import 'package:flutter/material.dart'; @@ -19,7 +20,10 @@ class MyApp extends StatelessWidget { theme: ThemeData( primarySwatch: Colors.blue, ), - home: const QueryBowlScope(child: MyHomePage()), + home: const QueryBowlScope( + staleTime: Duration(seconds: 10), + child: MyHomePage(), + ), ); } } @@ -40,24 +44,38 @@ class _MyHomePageState extends State { ), body: Column( children: [ - QueryBuilder( - queryKey: "greetings", - task: (queryKey) => Future.value( - "Welcome ($queryKey) ${Random.secure().nextInt(100)}"), - builder: (context, query) { - if (query.isLoading) return const CircularProgressIndicator(); - return Row( - children: [ - TextButton( + Row( + children: [ + QueryBuilder( + queryKey: "greetings", + task: (queryKey) => Future.delayed( + const Duration(seconds: 2), + () => + "Welcome ($queryKey) ${Random.secure().nextInt(100)}"), + builder: (context, query) { + if (query.isLoading || query.isRefetching) { + return const CircularProgressIndicator(); + } + return TextButton( child: Text(query.data!), onPressed: () async { await query.refetch(); }, - ), - ], - ); - }, - ) + ); + }, + ), + QueryBuilder( + queryKey: "failure", + task: (queryKey) => + Future.value("[$queryKey] Failed for unknown reason"), + builder: (context, query) { + if (query.hasError) return Text(query.error); + return Text("Failure. You're a failure ${query.data}"); + }, + ), + ], + ), + const AnotherComponent(), ], ), ); diff --git a/packages/fl_query/lib/query.dart b/packages/fl_query/lib/query.dart index 2ab44df..8c2308d 100644 --- a/packages/fl_query/lib/query.dart +++ b/packages/fl_query/lib/query.dart @@ -21,6 +21,9 @@ class Query extends ChangeNotifier { QueryTaskFunction task; final int retries; final Duration retryDelay; + final T? _initialData; + + // got from global options final Duration _staleTime; // all properties @@ -41,13 +44,14 @@ class Query extends ChangeNotifier { required this.queryKey, required this.task, required Duration staleTime, - this.retries = 3, - this.retryDelay = const Duration(milliseconds: 200), + required this.retries, + required this.retryDelay, T? initialData, QueryListener? onData, QueryListener? onError, }) : status = QueryStatus.pending, _staleTime = staleTime, + _initialData = initialData, data = initialData, _onData = onData, _onError = onError, @@ -60,49 +64,55 @@ class Query extends ChangeNotifier { bool get isLoading => status == QueryStatus.pending && data == null && error == null; bool get isRefetching => - status == QueryStatus.refetching && data == null && error == null; + status == QueryStatus.refetching && (data != null || error != null); bool get isSucceeded => status == QueryStatus.succeed && data != null; // all methods /// Calls the task function & doesn't check if there's already /// cached data available - Future _execute({bool isFetch = true}) async { + Future _execute() async { try { retryAttempts = 0; - status = isFetch ? QueryStatus.pending : QueryStatus.refetching; data = await task(queryKey); updatedAt = DateTime.now(); status = QueryStatus.succeed; _onData?.call(data!); notifyListeners(); } catch (e) { - status = QueryStatus.failed; - error = e; - _onError?.call(e); - notifyListeners(); - // retrying for retry count if failed for the first time - while (retryAttempts <= retries) { - await Future.delayed(retryDelay); - try { - data = await task(queryKey); - status = QueryStatus.succeed; - _onData?.call(data!); - notifyListeners(); - break; - } catch (e) { - status = QueryStatus.failed; - error = e; - retryAttempts++; - _onError?.call(e); - notifyListeners(); + if (retries == 0) { + status = QueryStatus.failed; + error = e; + _onError?.call(e); + notifyListeners(); + } else { + // retrying for retry count if failed for the first time + while (retryAttempts <= retries) { + await Future.delayed(retryDelay); + try { + data = await task(queryKey); + status = QueryStatus.succeed; + _onData?.call(data!); + notifyListeners(); + break; + } catch (e) { + if (retryAttempts == retries) { + status = QueryStatus.failed; + error = e; + _onError?.call(e); + notifyListeners(); + } + retryAttempts++; + } } } } } Future fetch() async { - if (!_isStaleData() && hasData) { + status = QueryStatus.pending; + notifyListeners(); + if (!isStale && hasData) { return data; } return _execute().then((_) { @@ -112,11 +122,43 @@ class Query extends ChangeNotifier { } Future refetch() { + status = QueryStatus.refetching; refetchCount++; - return _execute(isFetch: false).then((_) => data); + notifyListeners(); + return _execute().then((_) => data); } - bool _isStaleData() { + /// 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 + update(FutureOr Function(T? data) updateFn) async { + final newData = await updateFn(data); + if (data == newData) { + // TODO: Better Error handling & Error structure + throw Exception( + "[fl_query] new instance of data should be returned because of immutability"); + } + data = newData; + status = QueryStatus.succeed; + notifyListeners(); + } + + void reset() { + refetchCount = 0; + data = _initialData; + error = null; + fetched = false; + status = QueryStatus.pending; + retryAttempts = 0; + } + + bool get isStale { // when current DateTime is after [update_at + stale_time] it means // the data has become stale return DateTime.now().isAfter(updatedAt.add(_staleTime)); diff --git a/packages/fl_query/lib/query_bowl.dart b/packages/fl_query/lib/query_bowl.dart index 2e4fb20..c981909 100644 --- a/packages/fl_query/lib/query_bowl.dart +++ b/packages/fl_query/lib/query_bowl.dart @@ -1,13 +1,20 @@ +import 'dart:async'; + import 'package:fl_query/query.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; class QueryBowlScope extends StatefulWidget { final Widget child; - final Duration? staleTime; + final Duration staleTime; + + /// 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; const QueryBowlScope({ required this.child, - this.staleTime, + this.staleTime = const Duration(minutes: 5), + this.refreshInterval, Key? key, }) : super(key: key); @@ -18,10 +25,46 @@ class QueryBowlScope extends StatefulWidget { class _QueryBowlScopeState extends State { late Set queries; + late Timer refreshIntervalTimer; + @override void initState() { super.initState(); queries = {}; + refreshIntervalTimer = Timer.periodic( + widget.refreshInterval ?? + Duration( + milliseconds: (widget.staleTime.inMilliseconds / 2).round(), + ), + _checkAndUpdateStaleQueriesOnBg, + ); + } + + @override + void dispose() { + refreshIntervalTimer.cancel(); + _disposeListeners(); + super.dispose(); + } + + Future _checkAndUpdateStaleQueriesOnBg([dynamic _]) async { + // checking for staled queries inside the widget as InheritedWidget + // classes has to be constant & doesn't this kind of dynamic behavior + for (final query in queries) { + if (query.isStale) await query.refetch(); + } + } + + void _listenToQueryUpdate() { + for (final query in queries) { + query.addListener(updateQueries); + } + } + + void _disposeListeners() { + for (final query in queries) { + query.removeListener(updateQueries); + } } void updateQueries() { @@ -30,44 +73,47 @@ class _QueryBowlScopeState extends State { }); } + void addQuery(Query query) { + setState(() { + queries = Set.from({...queries, query}); + }); + } + @override Widget build(BuildContext context) { + _listenToQueryUpdate(); return QueryBowl( onUpdate: updateQueries, + addQuery: addQuery, queries: queries, + staleTime: widget.staleTime, child: widget.child, ); } } +/// QueryBowl holds all the query related methods & properties. +/// Its responsible for creating/updating/delete queries class QueryBowl extends InheritedWidget { - final Set queries; + final Set _queries; final Duration staleTime; - final void Function() onUpdate; + + final void Function(Query query) _addQuery; const QueryBowl({ required Widget child, - required this.onUpdate, - required this.queries, - this.staleTime = const Duration(minutes: 5), + required final void Function() onUpdate, + required final void Function(Query query) addQuery, + required final Set queries, + required this.staleTime, Key? key, - }) : super(child: child, key: key); - - listenToQueryUpdate() { - for (final query in queries) { - query.addListener(onUpdate); - } - } - - void disposeListeners() { - for (final query in queries) { - query.removeListener(onUpdate); - } - } + }) : _addQuery = addQuery, + _queries = queries, + super(child: child, key: key); Future fetchQuery(Query query) async { final prevQuery = - queries.firstWhereOrNull((q) => q.queryKey == query.queryKey); + _queries.firstWhereOrNull((q) => q.queryKey == query.queryKey); if (prevQuery is Query) { if (!prevQuery.hasData) { return prevQuery.fetched @@ -76,23 +122,37 @@ class QueryBowl extends InheritedWidget { } return prevQuery.data; } - queries.add(query); - disposeListeners(); - listenToQueryUpdate(); + _addQuery(query); return await query.fetch(); } Query? getQuery(String queryKey) { - return queries.firstWhereOrNull( + return _queries.firstWhereOrNull( (query) => query.queryKey == queryKey && query is Query) as Query?; } + int get isFetching { + return _queries.fold( + 0, + (acc, query) { + if (query.isLoading || query.isRefetching) acc++; + return acc; + }, + ); + } + + void resetQuery(String queryKey) { + _queries + .firstWhereOrNull((element) => element.queryKey == queryKey) + ?.reset(); + } + static QueryBowl of(BuildContext context) => context.dependOnInheritedWidgetOfExactType()!; @override bool updateShouldNotify(QueryBowl oldWidget) { - return oldWidget.staleTime != staleTime || oldWidget.queries != queries; + return oldWidget.staleTime != staleTime || oldWidget._queries != _queries; } } diff --git a/packages/fl_query/lib/query_builder.dart b/packages/fl_query/lib/query_builder.dart index 883c8a8..6522908 100644 --- a/packages/fl_query/lib/query_builder.dart +++ b/packages/fl_query/lib/query_builder.dart @@ -6,10 +6,24 @@ class QueryBuilder extends StatefulWidget { final Widget Function(BuildContext, Query) builder; final QueryTaskFunction task; final String queryKey; + final Duration? staleTime; + final int retries; + final T? initialData; + final Duration retryDelay; + + final QueryListener? onData; + final QueryListener? onError; + const QueryBuilder({ required this.builder, required this.task, required this.queryKey, + this.initialData, + this.staleTime, + this.retryDelay = const Duration(milliseconds: 200), + this.retries = 3, + this.onData, + this.onError, Key? key, }) : super(key: key); @@ -25,7 +39,12 @@ class _QueryBuilderState extends State> { await QueryBowl.of(context).fetchQuery(Query( queryKey: widget.queryKey, task: widget.task, - staleTime: QueryBowl.of(context).staleTime, + staleTime: widget.staleTime ?? QueryBowl.of(context).staleTime, + retries: widget.retries, + initialData: widget.initialData, + retryDelay: widget.retryDelay, + onData: widget.onData, + onError: widget.onError, )); }); } diff --git a/packages/fl_query/pubspec.yaml b/packages/fl_query/pubspec.yaml index 929e1c5..1fecce2 100644 --- a/packages/fl_query/pubspec.yaml +++ b/packages/fl_query/pubspec.yaml @@ -8,6 +8,7 @@ environment: flutter: ">=1.17.0" dependencies: + async: ^2.8.2 collection: ^1.16.0 flutter: sdk: flutter