2016-06-24 00:25:11 +00:00
|
|
|
/// Client library for the Angel framework.
|
2021-05-15 06:01:47 +00:00
|
|
|
library angel3_client;
|
2016-06-24 00:25:11 +00:00
|
|
|
|
|
|
|
import 'dart:async';
|
2017-12-10 05:13:31 +00:00
|
|
|
import 'package:collection/collection.dart';
|
2018-08-26 22:41:01 +00:00
|
|
|
import 'dart:convert';
|
2019-01-06 02:08:31 +00:00
|
|
|
import 'package:http/http.dart' as http;
|
2021-09-29 07:40:27 +00:00
|
|
|
//import 'package:logging/logging.dart';
|
2021-05-15 06:01:47 +00:00
|
|
|
export 'package:angel3_http_exception/angel3_http_exception.dart';
|
2016-06-24 19:02:35 +00:00
|
|
|
|
2016-06-24 21:06:57 +00:00
|
|
|
/// A function that configures an [Angel] client in some way.
|
2021-04-10 13:22:20 +00:00
|
|
|
typedef AngelConfigurer = FutureOr<void> Function(Angel app);
|
2016-06-24 19:02:35 +00:00
|
|
|
|
2016-12-13 16:35:35 +00:00
|
|
|
/// A function that deserializes data received from the server.
|
|
|
|
///
|
|
|
|
/// This is only really necessary in the browser, where `json_god`
|
|
|
|
/// doesn't work.
|
2021-04-10 13:22:20 +00:00
|
|
|
typedef AngelDeserializer<T> = T? Function(dynamic x);
|
2016-12-13 16:35:35 +00:00
|
|
|
|
2016-06-24 19:02:35 +00:00
|
|
|
/// Represents an Angel server that we are querying.
|
2019-01-06 02:08:31 +00:00
|
|
|
abstract class Angel extends http.BaseClient {
|
2021-09-26 06:53:42 +00:00
|
|
|
//final _log = Logger('Angel');
|
2021-07-15 08:11:54 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
/// A mutable member. When this is set, it holds a JSON Web Token
|
|
|
|
/// that is automatically attached to every request sent.
|
|
|
|
///
|
|
|
|
/// This is designed with `package:angel_auth` in mind.
|
2021-04-10 13:22:20 +00:00
|
|
|
String? authToken;
|
2016-06-24 19:02:35 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
/// The root URL at which the target server.
|
|
|
|
final Uri baseUrl;
|
|
|
|
|
|
|
|
Angel(baseUrl)
|
2021-04-26 00:47:32 +00:00
|
|
|
: baseUrl = baseUrl is Uri ? baseUrl : Uri.parse(baseUrl.toString());
|
2019-01-06 02:08:31 +00:00
|
|
|
|
|
|
|
/// Prefer to use [baseUrl] instead.
|
|
|
|
@deprecated
|
|
|
|
String get basePath => baseUrl.toString();
|
2016-06-24 21:06:57 +00:00
|
|
|
|
2017-06-03 17:43:01 +00:00
|
|
|
/// Fired whenever a WebSocket is successfully authenticated.
|
|
|
|
Stream<AngelAuthResult> get onAuthenticated;
|
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
/// Authenticates against the server.
|
|
|
|
///
|
|
|
|
/// This is designed with `package:angel_auth` in mind.
|
|
|
|
///
|
|
|
|
/// The [type] is appended to the [authEndpoint], ex. `local` becomes `/auth/local`.
|
|
|
|
///
|
|
|
|
/// The given [credentials] are sent to server as-is; the request body is sent as JSON.
|
2016-11-29 00:42:02 +00:00
|
|
|
Future<AngelAuthResult> authenticate(
|
2021-04-10 13:22:20 +00:00
|
|
|
{required String type,
|
2016-11-29 00:42:02 +00:00
|
|
|
credentials,
|
2019-01-06 02:08:31 +00:00
|
|
|
String authEndpoint = '/auth',
|
|
|
|
@deprecated String reviveEndpoint = '/auth/token'});
|
|
|
|
|
|
|
|
/// Shorthand for authenticating via a JWT string.
|
|
|
|
Future<AngelAuthResult> reviveJwt(String token,
|
|
|
|
{String authEndpoint = '/auth'}) {
|
|
|
|
return authenticate(
|
|
|
|
type: 'token',
|
|
|
|
credentials: {'token': token},
|
|
|
|
authEndpoint: authEndpoint);
|
|
|
|
}
|
2016-11-28 03:28:41 +00:00
|
|
|
|
2017-02-28 21:56:59 +00:00
|
|
|
/// Opens the [url] in a new window, and returns a [Stream] that will fire a JWT on successful authentication.
|
2019-01-06 02:08:31 +00:00
|
|
|
Stream<String> authenticateViaPopup(String url, {String eventName = 'token'});
|
2017-02-28 21:56:59 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
/// Disposes of any outstanding resources.
|
2021-02-21 02:47:23 +00:00
|
|
|
@override
|
2019-01-06 02:08:31 +00:00
|
|
|
Future<void> close();
|
2016-12-10 14:50:05 +00:00
|
|
|
|
2016-06-24 21:06:57 +00:00
|
|
|
/// Applies an [AngelConfigurer] to this instance.
|
2019-01-06 02:08:31 +00:00
|
|
|
Future<void> configure(AngelConfigurer configurer) async {
|
2016-06-24 21:06:57 +00:00
|
|
|
await configurer(this);
|
|
|
|
}
|
|
|
|
|
2017-03-29 01:52:19 +00:00
|
|
|
/// Logs the current user out of the application.
|
2019-01-06 02:08:31 +00:00
|
|
|
FutureOr<void> logout();
|
2017-03-29 01:52:19 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
/// Creates a [Service] instance that queries a given path on the server.
|
|
|
|
///
|
|
|
|
/// This expects that there is an Angel `Service` mounted on the server.
|
|
|
|
///
|
|
|
|
/// In other words, all endpoints will return [Data], except for the root of
|
|
|
|
/// [path], which returns a [List<Data>].
|
|
|
|
///
|
|
|
|
/// You can pass a custom [deserializer], which is typically necessary in cases where
|
|
|
|
/// `dart:mirrors` does not exist.
|
2018-10-02 15:42:26 +00:00
|
|
|
Service<Id, Data> service<Id, Data>(String path,
|
2021-04-10 13:22:20 +00:00
|
|
|
{@deprecated Type? type, AngelDeserializer<Data>? deserializer});
|
2016-12-10 17:15:54 +00:00
|
|
|
|
2021-03-07 16:02:53 +00:00
|
|
|
//@override
|
|
|
|
//Future<http.Response> delete(url, {Map<String, String> headers});
|
2016-12-10 17:15:54 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
@override
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<http.Response> get(url, {Map<String, String>? headers});
|
2016-12-10 17:15:54 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
@override
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<http.Response> head(url, {Map<String, String>? headers});
|
2016-12-10 17:15:54 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
@override
|
|
|
|
Future<http.Response> patch(url,
|
2021-04-10 13:22:20 +00:00
|
|
|
{body, Map<String, String>? headers, Encoding? encoding});
|
2016-12-10 17:15:54 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
@override
|
|
|
|
Future<http.Response> post(url,
|
2021-04-10 13:22:20 +00:00
|
|
|
{body, Map<String, String>? headers, Encoding? encoding});
|
2016-12-10 17:15:54 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
@override
|
|
|
|
Future<http.Response> put(url,
|
2021-04-10 13:22:20 +00:00
|
|
|
{body, Map<String, String>? headers, Encoding? encoding});
|
2016-06-24 19:02:35 +00:00
|
|
|
}
|
2016-06-24 00:25:11 +00:00
|
|
|
|
2016-11-28 03:28:41 +00:00
|
|
|
/// Represents the result of authentication with an Angel server.
|
2016-12-09 00:24:07 +00:00
|
|
|
class AngelAuthResult {
|
2021-04-10 13:22:20 +00:00
|
|
|
String? _token;
|
2016-12-09 00:24:07 +00:00
|
|
|
final Map<String, dynamic> data = {};
|
2019-01-06 02:08:31 +00:00
|
|
|
|
|
|
|
/// The JSON Web token that was sent with this response.
|
2021-04-10 13:22:20 +00:00
|
|
|
String? get token => _token;
|
2016-12-09 00:24:07 +00:00
|
|
|
|
2021-04-10 13:22:20 +00:00
|
|
|
AngelAuthResult({String? token, Map<String, dynamic> data = const {}}) {
|
2016-12-09 00:24:07 +00:00
|
|
|
_token = token;
|
2021-04-10 13:22:20 +00:00
|
|
|
this.data.addAll(data);
|
2016-12-09 00:24:07 +00:00
|
|
|
}
|
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
/// Attempts to deserialize a response from a [Map].
|
2021-04-10 13:22:20 +00:00
|
|
|
factory AngelAuthResult.fromMap(Map? data) {
|
2021-03-05 07:51:48 +00:00
|
|
|
final result = AngelAuthResult();
|
2016-12-09 00:24:07 +00:00
|
|
|
|
2021-04-10 13:22:20 +00:00
|
|
|
if (data is Map && data.containsKey('token') && data['token'] is String) {
|
2018-08-26 22:41:01 +00:00
|
|
|
result._token = data['token'].toString();
|
2021-04-10 13:22:20 +00:00
|
|
|
}
|
2016-12-09 00:24:07 +00:00
|
|
|
|
2021-04-10 13:22:20 +00:00
|
|
|
if (data is Map) {
|
|
|
|
result.data.addAll((data['data'] as Map<String, dynamic>?) ?? {});
|
|
|
|
}
|
2016-12-03 18:21:44 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
if (result.token == null) {
|
2021-03-05 07:51:48 +00:00
|
|
|
throw FormatException(
|
2019-01-06 02:08:31 +00:00
|
|
|
'The required "token" field was not present in the given data.');
|
2021-04-10 13:22:20 +00:00
|
|
|
} else if (data!['data'] is! Map) {
|
2021-03-05 07:51:48 +00:00
|
|
|
throw FormatException(
|
2019-01-06 02:08:31 +00:00
|
|
|
'The required "data" field in the given data was not a map; instead, it was ${data['data']}.');
|
|
|
|
}
|
|
|
|
|
2016-12-09 00:24:07 +00:00
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
/// Attempts to deserialize a response from a [String].
|
2018-06-23 00:18:38 +00:00
|
|
|
factory AngelAuthResult.fromJson(String s) =>
|
2021-04-10 13:22:20 +00:00
|
|
|
AngelAuthResult.fromMap(json.decode(s) as Map?);
|
2016-12-09 00:24:07 +00:00
|
|
|
|
2019-01-06 02:08:31 +00:00
|
|
|
/// Converts this instance into a JSON-friendly representation.
|
2016-12-09 00:24:07 +00:00
|
|
|
Map<String, dynamic> toJson() {
|
|
|
|
return {'token': token, 'data': data};
|
|
|
|
}
|
2016-11-28 03:28:41 +00:00
|
|
|
}
|
|
|
|
|
2016-06-24 00:25:11 +00:00
|
|
|
/// Queries a service on an Angel server, with the same API.
|
2018-10-02 15:42:26 +00:00
|
|
|
abstract class Service<Id, Data> {
|
2017-06-03 17:43:01 +00:00
|
|
|
/// Fired on `indexed` events.
|
2018-11-04 01:34:21 +00:00
|
|
|
Stream<List<Data>> get onIndexed;
|
2017-06-03 17:43:01 +00:00
|
|
|
|
|
|
|
/// Fired on `read` events.
|
2018-10-02 15:42:26 +00:00
|
|
|
Stream<Data> get onRead;
|
2017-06-03 17:43:01 +00:00
|
|
|
|
|
|
|
/// Fired on `created` events.
|
2018-10-02 15:42:26 +00:00
|
|
|
Stream<Data> get onCreated;
|
2017-06-03 17:43:01 +00:00
|
|
|
|
|
|
|
/// Fired on `modified` events.
|
2018-10-02 15:42:26 +00:00
|
|
|
Stream<Data> get onModified;
|
2017-06-03 17:43:01 +00:00
|
|
|
|
|
|
|
/// Fired on `updated` events.
|
2018-10-02 15:42:26 +00:00
|
|
|
Stream<Data> get onUpdated;
|
2017-06-03 17:43:01 +00:00
|
|
|
|
|
|
|
/// Fired on `removed` events.
|
2018-10-02 15:42:26 +00:00
|
|
|
Stream<Data> get onRemoved;
|
2017-06-03 17:43:01 +00:00
|
|
|
|
2016-06-24 21:06:57 +00:00
|
|
|
/// The Angel instance powering this service.
|
2016-11-28 03:28:41 +00:00
|
|
|
Angel get app;
|
2016-06-24 21:06:57 +00:00
|
|
|
|
2017-06-03 17:43:01 +00:00
|
|
|
Future close();
|
|
|
|
|
2016-06-24 00:25:11 +00:00
|
|
|
/// Retrieves all resources.
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<List<Data>?> index([Map<String, dynamic>? params]);
|
2016-06-24 00:25:11 +00:00
|
|
|
|
|
|
|
/// Retrieves the desired resource.
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<Data> read(Id id, [Map<String, dynamic>? params]);
|
2016-06-24 00:25:11 +00:00
|
|
|
|
|
|
|
/// Creates a resource.
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<Data> create(Data data, [Map<String, dynamic>? params]);
|
2016-06-24 00:25:11 +00:00
|
|
|
|
|
|
|
/// Modifies a resource.
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<Data> modify(Id id, Data data, [Map<String, dynamic>? params]);
|
2016-06-24 00:25:11 +00:00
|
|
|
|
|
|
|
/// Overwrites a resource.
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<Data> update(Id id, Data data, [Map<String, dynamic>? params]);
|
2016-06-24 00:25:11 +00:00
|
|
|
|
|
|
|
/// Removes the given resource.
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<Data> remove(Id id, [Map<String, dynamic>? params]);
|
2018-11-04 01:34:21 +00:00
|
|
|
|
|
|
|
/// Creates a [Service] that wraps over this one, and maps input and output using two converter functions.
|
|
|
|
///
|
|
|
|
/// Handy utility for handling data in a type-safe manner.
|
|
|
|
Service<Id, U> map<U>(U Function(Data) encoder, Data Function(U) decoder) {
|
2021-03-05 07:51:48 +00:00
|
|
|
return _MappedService(this, encoder, decoder);
|
2018-11-04 01:34:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _MappedService<Id, Data, U> extends Service<Id, U> {
|
|
|
|
final Service<Id, Data> inner;
|
|
|
|
final U Function(Data) encoder;
|
|
|
|
final Data Function(U) decoder;
|
|
|
|
|
|
|
|
_MappedService(this.inner, this.encoder, this.decoder);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Angel get app => inner.app;
|
|
|
|
|
|
|
|
@override
|
2021-03-05 07:51:48 +00:00
|
|
|
Future close() => Future.value();
|
2018-11-04 01:34:21 +00:00
|
|
|
|
|
|
|
@override
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<U> create(U data, [Map<String, dynamic>? params]) {
|
2018-11-04 01:34:21 +00:00
|
|
|
return inner.create(decoder(data)).then(encoder);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<List<U>> index([Map<String, dynamic>? params]) {
|
|
|
|
return inner.index(params).then((l) => l!.map(encoder).toList());
|
2018-11-04 01:34:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<U> modify(Id id, U data, [Map<String, dynamic>? params]) {
|
2018-11-04 01:34:21 +00:00
|
|
|
return inner.modify(id, decoder(data), params).then(encoder);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Stream<U> get onCreated => inner.onCreated.map(encoder);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Stream<List<U>> get onIndexed =>
|
|
|
|
inner.onIndexed.map((l) => l.map(encoder).toList());
|
|
|
|
|
|
|
|
@override
|
|
|
|
Stream<U> get onModified => inner.onModified.map(encoder);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Stream<U> get onRead => inner.onRead.map(encoder);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Stream<U> get onRemoved => inner.onRemoved.map(encoder);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Stream<U> get onUpdated => inner.onUpdated.map(encoder);
|
|
|
|
|
|
|
|
@override
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<U> read(Id id, [Map<String, dynamic>? params]) {
|
2018-11-04 01:34:21 +00:00
|
|
|
return inner.read(id, params).then(encoder);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<U> remove(Id id, [Map<String, dynamic>? params]) {
|
2018-11-04 01:34:21 +00:00
|
|
|
return inner.remove(id, params).then(encoder);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
2021-04-10 13:22:20 +00:00
|
|
|
Future<U> update(Id id, U data, [Map<String, dynamic>? params]) {
|
2018-11-04 01:34:21 +00:00
|
|
|
return inner.update(id, decoder(data), params).then(encoder);
|
|
|
|
}
|
2016-09-03 12:02:32 +00:00
|
|
|
}
|
2017-12-10 05:13:31 +00:00
|
|
|
|
|
|
|
/// A [List] that automatically updates itself whenever the referenced [service] fires an event.
|
2018-11-04 01:34:21 +00:00
|
|
|
class ServiceList<Id, Data> extends DelegatingList<Data> {
|
2017-12-10 05:13:31 +00:00
|
|
|
/// A field name used to compare [Map] by ID.
|
|
|
|
final String idField;
|
|
|
|
|
2017-12-21 20:08:45 +00:00
|
|
|
/// A function used to compare the ID's two items for equality.
|
|
|
|
///
|
|
|
|
/// Defaults to comparing the [idField] of `Map` instances.
|
2021-04-10 13:22:20 +00:00
|
|
|
Equality<Data>? get equality => _equality;
|
2018-11-04 01:34:21 +00:00
|
|
|
|
2021-04-10 13:22:20 +00:00
|
|
|
Equality<Data>? _equality;
|
2017-12-10 05:13:31 +00:00
|
|
|
|
2018-10-02 15:42:26 +00:00
|
|
|
final Service<Id, Data> service;
|
2017-12-10 05:13:31 +00:00
|
|
|
|
2021-03-05 07:51:48 +00:00
|
|
|
final StreamController<ServiceList<Id, Data>> _onChange = StreamController();
|
2018-11-04 01:34:21 +00:00
|
|
|
|
2017-12-10 05:13:31 +00:00
|
|
|
final List<StreamSubscription> _subs = [];
|
|
|
|
|
2021-04-10 13:22:20 +00:00
|
|
|
ServiceList(this.service, {this.idField = 'id', Equality<Data>? equality})
|
2018-11-04 01:34:21 +00:00
|
|
|
: super([]) {
|
|
|
|
_equality = equality;
|
2021-04-10 13:22:20 +00:00
|
|
|
_equality ??= EqualityBy<Data, Id?>((map) {
|
|
|
|
if (map is Map) {
|
|
|
|
return map[idField] as Id?;
|
|
|
|
} else {
|
2021-03-05 07:51:48 +00:00
|
|
|
throw UnsupportedError(
|
2018-11-04 01:34:21 +00:00
|
|
|
'ServiceList only knows how to find the id from a Map object. Provide a custom `Equality` in your call to the constructor.');
|
2021-04-10 13:22:20 +00:00
|
|
|
}
|
2018-11-04 01:34:21 +00:00
|
|
|
});
|
2017-12-10 05:13:31 +00:00
|
|
|
// Index
|
2018-11-04 01:34:21 +00:00
|
|
|
_subs.add(service.onIndexed.where(_notNull).listen((data) {
|
2017-12-13 16:31:06 +00:00
|
|
|
this
|
|
|
|
..clear()
|
2018-11-04 01:34:21 +00:00
|
|
|
..addAll(data);
|
2017-12-13 16:31:06 +00:00
|
|
|
_onChange.add(this);
|
2017-12-10 05:13:31 +00:00
|
|
|
}));
|
|
|
|
|
|
|
|
// Created
|
2018-11-04 01:34:21 +00:00
|
|
|
_subs.add(service.onCreated.where(_notNull).listen((item) {
|
2017-12-10 05:13:31 +00:00
|
|
|
add(item);
|
|
|
|
_onChange.add(this);
|
|
|
|
}));
|
|
|
|
|
|
|
|
// Modified/Updated
|
2021-04-10 13:22:20 +00:00
|
|
|
void handleModified(Data item) {
|
2017-12-10 05:13:31 +00:00
|
|
|
var indices = <int>[];
|
|
|
|
|
2021-04-10 13:22:20 +00:00
|
|
|
for (var i = 0; i < length; i++) {
|
|
|
|
if (_equality!.equals(item, this[i])) indices.add(i);
|
2017-12-10 05:13:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (indices.isNotEmpty) {
|
2021-04-10 13:22:20 +00:00
|
|
|
for (var i in indices) {
|
|
|
|
this[i] = item;
|
|
|
|
}
|
2017-12-10 05:13:31 +00:00
|
|
|
|
|
|
|
_onChange.add(this);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_subs.addAll([
|
2018-11-04 01:34:21 +00:00
|
|
|
service.onModified.where(_notNull).listen(handleModified),
|
|
|
|
service.onUpdated.where(_notNull).listen(handleModified),
|
2017-12-10 05:13:31 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
// Removed
|
2018-11-04 01:34:21 +00:00
|
|
|
_subs.add(service.onRemoved.where(_notNull).listen((item) {
|
2021-04-10 13:22:20 +00:00
|
|
|
removeWhere((x) => _equality!.equals(item, x));
|
2017-12-10 05:13:31 +00:00
|
|
|
_onChange.add(this);
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
2018-11-04 01:34:21 +00:00
|
|
|
static bool _notNull(x) => x != null;
|
|
|
|
|
2017-12-10 05:13:31 +00:00
|
|
|
/// Fires whenever the underlying [service] fires a change event.
|
2018-10-02 15:42:26 +00:00
|
|
|
Stream<ServiceList<Id, Data>> get onChange => _onChange.stream;
|
2017-12-10 05:13:31 +00:00
|
|
|
|
|
|
|
Future close() async {
|
2019-04-20 15:21:15 +00:00
|
|
|
await _onChange.close();
|
2017-12-10 05:13:31 +00:00
|
|
|
}
|
|
|
|
}
|