diff --git a/packages/example/lib/main.dart b/packages/example/lib/main.dart index 2908610..a88985f 100644 --- a/packages/example/lib/main.dart +++ b/packages/example/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:example/another_component.dart'; import 'package:example/lazy_query.dart'; +import 'package:example/mutation_example.dart'; import 'package:example/query_with_external_data.dart'; import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; @@ -137,6 +138,17 @@ class _MyHomePageState extends State { ); }, ), + const SizedBox(height: 10), + ElevatedButton( + child: const Text("Mutation Example"), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const MutationExample(), + ), + ); + }, + ), const AnotherComponent(), ], ), diff --git a/packages/example/lib/mutation_example.dart b/packages/example/lib/mutation_example.dart new file mode 100644 index 0000000..b582a15 --- /dev/null +++ b/packages/example/lib/mutation_example.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:fl_query/models/mutation_job.dart'; +import 'package:fl_query/mutation_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +final postSomethingJob = MutationJob>( + mutationKey: "post-something-job", + task: (key, data) async { + final response = await http.post( + Uri.parse( + "https://jsonplaceholder.typicode.com/posts", + ), + headers: {'Content-type': 'application/json; charset=UTF-8'}, + body: jsonEncode(data), + ); + return jsonDecode(response.body); + }, +); + +class MutationExample extends StatefulWidget { + const MutationExample({Key? key}) : super(key: key); + + @override + State createState() => _MutationExampleState(); +} + +class _MutationExampleState extends State { + late TextEditingController titleController; + late TextEditingController bodyController; + late int id; + @override + void initState() { + super.initState(); + id = Random().nextInt(2000000); + titleController = TextEditingController(); + bodyController = TextEditingController(); + } + + @override + void dispose() { + titleController.dispose(); + bodyController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Post Something")), + body: MutationBuilder>( + job: postSomethingJob, + builder: (context, mutation) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration(labelText: "Title"), + ), + TextField( + controller: bodyController, + decoration: const InputDecoration(labelText: "Body"), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () { + final title = titleController.value.text; + final body = bodyController.value.text; + if (body.isEmpty || title.isEmpty) return; + mutation.mutate({ + "title": title, + "body": body, + "id": id, + }, onData: (data) { + // resetting the form + titleController.text = ""; + bodyController.text = ""; + }); + }, + child: const Text("Post"), + ), + const SizedBox(height: 20), + if (mutation.hasData) Text("Response\n${mutation.data}") + ], + ), + ); + }), + ); + } +} diff --git a/packages/example/pubspec.lock b/packages/example/pubspec.lock index c4ad696..bba5262 100644 --- a/packages/example/pubspec.lock +++ b/packages/example/pubspec.lock @@ -81,6 +81,20 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" lints: dependency: transitive description: @@ -163,6 +177,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.9" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" vector_math: dependency: transitive description: diff --git a/packages/example/pubspec.yaml b/packages/example/pubspec.yaml index 3464ab1..2526f6f 100644 --- a/packages/example/pubspec.yaml +++ b/packages/example/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: cupertino_icons: ^1.0.2 fl_query: path: ../fl_query + http: ^0.13.4 dev_dependencies: flutter_test: diff --git a/packages/fl_query/lib/models/mutation_job.dart b/packages/fl_query/lib/models/mutation_job.dart new file mode 100644 index 0000000..9487da2 --- /dev/null +++ b/packages/fl_query/lib/models/mutation_job.dart @@ -0,0 +1,17 @@ +import 'package:fl_query/mutation.dart'; + +class MutationJob { + final String mutationKey; + MutationTaskFunction task; + final int? retries; + final Duration? retryDelay; + final Duration? cacheTime; + + MutationJob({ + required this.mutationKey, + required this.task, + this.retries, + this.retryDelay, + this.cacheTime, + }); +} diff --git a/packages/fl_query/lib/mutation.dart b/packages/fl_query/lib/mutation.dart new file mode 100644 index 0000000..16d7264 --- /dev/null +++ b/packages/fl_query/lib/mutation.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:fl_query/models/mutation_job.dart'; +import 'package:flutter/widgets.dart'; + +enum MutationStatus { + failed, + succeed, + pending, +} + +typedef MutationListener = FutureOr Function(T); + +typedef MutationTaskFunction = FutureOr Function(String, V); + +class Mutation extends ChangeNotifier { + // all params + final String mutationKey; + MutationTaskFunction task; + final int retries; + final Duration retryDelay; + final Duration _cacheTime; + + // all properties + T? data; + dynamic error; + MutationStatus status; + + /// total count of how many times the query retried to get a successful + /// result + int retryAttempts = 0; + DateTime updatedAt; + + /// used for keeping track of mutation activity. If the are no mounts & + /// the passed cached time is over than the mutation is removed from + /// storage/cache + Set _mounts = {}; + + @protected + final Set> onDataListeners = {}; + @protected + final Set> onErrorListeners = {}; + @protected + final Set> onMutateListeners = {}; + + Mutation({ + required this.mutationKey, + required this.task, + required this.retries, + required this.retryDelay, + required Duration cacheTime, + MutationListener? onData, + MutationListener? onError, + MutationListener? onMutate, + }) : status = MutationStatus.pending, + updatedAt = DateTime.now(), + _cacheTime = cacheTime { + if (onData != null) onDataListeners.add(onData); + if (onError != null) onErrorListeners.add(onError); + if (onMutate != null) onMutateListeners.add(onMutate); + } + + Mutation.fromOptions( + MutationJob options, { + MutationListener? onData, + MutationListener? onError, + MutationListener? onMutate, + }) : mutationKey = options.mutationKey, + task = options.task, + retries = options.retries ?? 3, + retryDelay = options.retryDelay ?? const Duration(milliseconds: 200), + _cacheTime = options.cacheTime ?? const Duration(minutes: 5), + status = MutationStatus.pending, + updatedAt = DateTime.now() { + if (onData != null) onDataListeners.add(onData); + if (onError != null) onErrorListeners.add(onError); + } + + // all getters & setters + bool get hasData => data != null && error == null; + bool get hasError => + status == MutationStatus.failed && error != null && data == null; + bool get isLoading => + status == MutationStatus.pending && data == null && error == null; + bool get isSucceeded => status == MutationStatus.succeed && data != null; + bool get isIdle => isSucceeded && error == null; + bool get isInactive => _mounts.isEmpty; + // 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 _execMutation(V variables) async { + try { + retryAttempts = 0; + for (final onMutate in onMutateListeners) { + onMutate(variables); + } + data = await task(mutationKey, variables); + updatedAt = DateTime.now(); + status = MutationStatus.succeed; + for (final onData in onDataListeners) { + onData(data!); + } + notifyListeners(); + } catch (e) { + if (retries == 0) { + status = MutationStatus.failed; + error = e; + for (final onError in onErrorListeners) { + onError(error); + } + notifyListeners(); + } else { + // retrying for retry count if failed for the first time + while (retryAttempts <= retries) { + await Future.delayed(retryDelay); + try { + for (final onMutate in onMutateListeners) { + onMutate(variables); + } + data = await task(mutationKey, variables); + status = MutationStatus.succeed; + for (final onData in onDataListeners) { + onData(data!); + } + notifyListeners(); + break; + } catch (e) { + if (retryAttempts == retries) { + status = MutationStatus.failed; + error = e; + for (final onError in onErrorListeners) { + onError(error); + } + notifyListeners(); + } + retryAttempts++; + } + } + } + } + } + + void mutate( + V variables, { + MutationListener? onData, + MutationListener? onError, + }) { + if (onData != null) onDataListeners.add(onData); + if (onError != null) onErrorListeners.add(onError); + _execMutation(variables).then((_) { + onDataListeners.remove(onData); + onErrorListeners.remove(onError); + }); + } + + Future mutateAsync(V variables) async { + return await _execMutation(variables).then((_) => data); + } + + reset() { + data = null; + retryAttempts = 0; + updatedAt = DateTime.now(); + onDataListeners.clear(); + onErrorListeners.clear(); + status = MutationStatus.pending; + onMutateListeners.clear(); + } + + A? cast() => this is A ? this as A : null; +} diff --git a/packages/fl_query/lib/mutation_builder.dart b/packages/fl_query/lib/mutation_builder.dart new file mode 100644 index 0000000..367f724 --- /dev/null +++ b/packages/fl_query/lib/mutation_builder.dart @@ -0,0 +1,73 @@ +import 'package:fl_query/models/mutation_job.dart'; +import 'package:fl_query/mutation.dart'; +import 'package:fl_query/query_bowl.dart'; +import 'package:flutter/widgets.dart'; + +class MutationBuilder extends StatefulWidget { + final Function(BuildContext, Mutation) builder; + final MutationJob job; + + /// Called when the query returns new data, on query + /// refetch or query gets expired + final MutationListener? onData; + + /// Called when the query returns error + final MutationListener? onError; + + /// called right before the mutation is about to run + /// + /// perfect scenario for doing optimistic updates + final MutationListener? onMutate; + + const MutationBuilder({ + required this.job, + required this.builder, + this.onData, + this.onError, + this.onMutate, + Key? key, + }) : super(key: key); + + @override + State> createState() => _MutationBuilderState(); +} + +class _MutationBuilderState + extends State> { + late QueryBowl queryBowl; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + queryBowl = QueryBowl.of(context); + queryBowl.addMutation( + widget.job, + onData: widget.onData, + onError: widget.onError, + onMutate: widget.onMutate, + mount: widget, + ); + }); + } + + @override + void dispose() { + final mutation = queryBowl.getMutation(widget.job.mutationKey); + mutation?.unmount(widget); + if (widget.onData != null) mutation?.onDataListeners.remove(widget.onData); + if (widget.onError != null) + mutation?.onErrorListeners.remove(widget.onError); + if (widget.onMutate != null) + mutation?.onMutateListeners.remove(widget.onMutate); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + queryBowl = QueryBowl.of(context); + final mutation = queryBowl.getMutation(widget.job.mutationKey); + if (mutation == null) return Container(); + return widget.builder(context, mutation); + } +} diff --git a/packages/fl_query/lib/query.dart b/packages/fl_query/lib/query.dart index c70f801..59446a4 100644 --- a/packages/fl_query/lib/query.dart +++ b/packages/fl_query/lib/query.dart @@ -46,8 +46,11 @@ class Query extends ChangeNotifier { @protected bool fetched = false; - final QueryListener? _onData; - final QueryListener? _onError; + @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 @@ -78,9 +81,10 @@ class Query extends ChangeNotifier { _initialData = initialData, _externalData = externalData, data = initialData, - _onData = onData, - _onError = onError, - updatedAt = DateTime.now(); + updatedAt = DateTime.now() { + if (onData != null) onDataListeners.add(onData); + if (onError != null) onErrorListeners.add(onError); + } Query.fromOptions( QueryJob options, { @@ -96,11 +100,12 @@ class Query extends ChangeNotifier { _cacheTime = options.cacheTime ?? const Duration(minutes: 5), _initialData = options.initialData, _externalData = externalData, - _onData = onData, - _onError = onError, data = options.initialData, status = QueryStatus.pending, - updatedAt = DateTime.now(); + updatedAt = DateTime.now() { + if (onData != null) onDataListeners.add(onData); + if (onError != null) onErrorListeners.add(onError); + } // all getters & setters bool get hasData => data != null && error == null; @@ -144,13 +149,17 @@ class Query extends ChangeNotifier { _prevUsedExternalData = _externalData; updatedAt = DateTime.now(); status = QueryStatus.succeed; - _onData?.call(data!); + for (final onData in onDataListeners) { + onData(data!); + } notifyListeners(); } catch (e) { if (retries == 0) { status = QueryStatus.failed; error = e; - _onError?.call(e); + for (final onError in onErrorListeners) { + onError(error); + } notifyListeners(); } else { // retrying for retry count if failed for the first time @@ -160,14 +169,18 @@ class Query extends ChangeNotifier { data = await task(queryKey, _externalData); _prevUsedExternalData = _externalData; status = QueryStatus.succeed; - _onData?.call(data!); + for (final onData in onDataListeners) { + onData(data!); + } notifyListeners(); break; } catch (e) { if (retryAttempts == retries) { status = QueryStatus.failed; error = e; - _onError?.call(e); + for (final onError in onErrorListeners) { + onError(error); + } notifyListeners(); } retryAttempts++; @@ -234,6 +247,9 @@ class Query extends ChangeNotifier { fetched = false; status = QueryStatus.pending; retryAttempts = 0; + onDataListeners.clear(); + onErrorListeners.clear(); + _mounts.clear(); } bool get isStale { diff --git a/packages/fl_query/lib/query_bowl.dart b/packages/fl_query/lib/query_bowl.dart index cce7e71..0708b7b 100644 --- a/packages/fl_query/lib/query_bowl.dart +++ b/packages/fl_query/lib/query_bowl.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:fl_query/models/mutation_job.dart'; import 'package:fl_query/models/query_job.dart'; +import 'package:fl_query/mutation.dart'; import 'package:fl_query/query.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; @@ -27,6 +29,7 @@ class QueryBowlScope extends StatefulWidget { class _QueryBowlScopeState extends State { late Set queries; + late Set mutations; late Timer refreshIntervalTimer; @@ -34,6 +37,7 @@ class _QueryBowlScopeState extends State { void initState() { super.initState(); queries = {}; + mutations = {}; refreshIntervalTimer = Timer.periodic( widget.refreshInterval, _checkAndUpdateStaleQueriesOnBg, @@ -43,7 +47,7 @@ class _QueryBowlScopeState extends State { @override void dispose() { refreshIntervalTimer.cancel(); - _disposeListeners(); + _disposeUpdateListeners(); super.dispose(); } @@ -55,16 +59,22 @@ class _QueryBowlScopeState extends State { } } - void _listenToQueryUpdate() { + void _listenToUpdates() { for (final query in queries) { query.addListener(() => updateQueries(query)); } + for (final mutation in mutations) { + mutation.addListener(() => updateMutations(mutation)); + } } - void _disposeListeners() { + void _disposeUpdateListeners() { for (final query in queries) { query.removeListener(() => updateQueries(query)); } + for (final mutation in mutations) { + mutation.removeListener(() => updateMutations(mutation)); + } } void updateQueries(Query query) { @@ -79,19 +89,41 @@ class _QueryBowlScopeState extends State { }); } + void updateMutations(Mutation mutation) { + setState(() { + // checking & not including inactive mutations + // basically garbage collecting mutations + mutations = Set.from( + mutation.isInactive + ? mutations.where( + (el) => el.mutationKey != mutation.mutationKey, + ) + : mutations, + ); + }); + } + void addQuery(Query query) { setState(() { queries = Set.from({...queries, query}); }); } + void addMutation(Mutation mutation) { + setState(() { + mutations = Set.from({...mutations, mutation}); + }); + } + @override Widget build(BuildContext context) { - _disposeListeners(); - _listenToQueryUpdate(); + _disposeUpdateListeners(); + _listenToUpdates(); return QueryBowl( addQuery: addQuery, + addMutation: addMutation, queries: queries, + mutations: mutations, staleTime: widget.staleTime, child: widget.child, ); @@ -102,21 +134,30 @@ class _QueryBowlScopeState extends State { /// Its responsible for creating/updating/delete queries class QueryBowl extends InheritedWidget { final Set _queries; + final Set _mutations; final Duration staleTime; final void Function(Query query) _addQuery; + final void Function(Mutation mutation) + _addMutation; + const QueryBowl({ required Widget child, required final void Function( Query query) addQuery, + required final void Function(Mutation mutation) + addMutation, required final Set queries, + required final Set mutations, required this.staleTime, Key? key, }) : _addQuery = addQuery, _queries = queries, + _mutations = mutations, + _addMutation = addMutation, super(child: child, key: key); Future fetchQuery( @@ -134,6 +175,8 @@ class QueryBowl extends InheritedWidget { final hasExternalDataChanged = prevQuery.prevUsedExternalData != externalData; if (mount != null) prevQuery.mount(mount); + if (onData != null) prevQuery.onDataListeners.add(onData); + if (onError != null) prevQuery.onErrorListeners.add(onError); if (!prevQuery.hasData || hasExternalDataChanged) { if (hasExternalDataChanged) prevQuery.setExternalData(externalData); return prevQuery.fetched @@ -154,12 +197,44 @@ class QueryBowl extends InheritedWidget { return await query.fetch(); } + void addMutation( + MutationJob options, { + final MutationListener? onData, + final MutationListener? onError, + final MutationListener? onMutate, + Widget? mount, + }) { + final prevMutation = _mutations.firstWhereOrNull( + (mutation) => mutation.mutationKey == options.mutationKey); + if (prevMutation != null && prevMutation is Mutation) { + if (onData != null) prevMutation.onDataListeners.add(onData); + if (onError != null) prevMutation.onErrorListeners.add(onError); + if (onMutate != null) prevMutation.onMutateListeners.add(onMutate); + if (mount != null) prevMutation.mount(mount); + } else { + final mutation = Mutation.fromOptions( + options, + onData: onData, + onError: onError, + onMutate: onMutate, + ); + if (mount != null) mutation.mount(mount); + _addMutation(mutation); + } + } + Query? getQuery(String queryKey) { return _queries.firstWhereOrNull((query) { return query.queryKey == queryKey && query is Query; })?.cast>(); } + Mutation? getMutation(String mutationKey) { + return _mutations.firstWhereOrNull((mutation) { + return mutation.mutationKey == mutationKey && mutation is Mutation; + })?.cast>(); + } + int get isFetching { return _queries.fold( 0, @@ -170,6 +245,16 @@ class QueryBowl extends InheritedWidget { ); } + int get isMutating { + return _mutations.fold( + 0, + (acc, mutation) { + if (mutation.isLoading) acc++; + return acc; + }, + ); + } + void resetQuery(String queryKey) { _queries .firstWhereOrNull((element) => element.queryKey == queryKey) @@ -181,6 +266,8 @@ class QueryBowl extends InheritedWidget { @override bool updateShouldNotify(QueryBowl oldWidget) { - return oldWidget.staleTime != staleTime || oldWidget._queries != _queries; + return oldWidget.staleTime != staleTime || + oldWidget._queries != _queries || + oldWidget._mutations != _mutations; } } diff --git a/packages/fl_query/lib/query_builder.dart b/packages/fl_query/lib/query_builder.dart index 1a28809..6a69dae 100644 --- a/packages/fl_query/lib/query_builder.dart +++ b/packages/fl_query/lib/query_builder.dart @@ -68,7 +68,10 @@ class _QueryBuilderState @override void dispose() { - queryBowl.getQuery(widget.job.queryKey)?.unmount(widget); + final query = queryBowl.getQuery(widget.job.queryKey); + query?.unmount(widget); + if (widget.onData != null) query?.onDataListeners.remove(widget.onData); + if (widget.onError != null) query?.onErrorListeners.remove(widget.onError); super.dispose(); }