feat: implement some api; add md editor to app; login and signup logic.
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
## v2.0.1
|
||||||
|
* Fixed issue when adding multiple commands without waiting for them to complete first.
|
||||||
|
|
||||||
|
## v2.0.0
|
||||||
|
* Migrated to null-safety, dropping dependency on `package:dartis`.
|
||||||
|
|
||||||
|
## v1.0.2
|
||||||
|
* Upgrade `package:dartis`.
|
||||||
|
|
||||||
|
## v1.0.1
|
||||||
|
* Avoid unnecessary purging when calling `set` with a `create` function that
|
||||||
|
returns `null`.
|
||||||
|
|
||||||
|
## v1.0.0
|
||||||
|
* Initial release.
|
||||||
Vendored
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
Vendored
+39
@@ -0,0 +1,39 @@
|
|||||||
|
Neat Cache
|
||||||
|
==========
|
||||||
|
|
||||||
|
Abstractions around in-memory caches stores such as redis, with timeouts and
|
||||||
|
automatic reconnects.
|
||||||
|
|
||||||
|
**Disclaimer:** This is not an officially supported Google product.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:async' show Future;
|
||||||
|
import 'dart:convert' show utf8;
|
||||||
|
import 'package:neat_cache/neat_cache.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
final cacheProvider = Cache.redisCacheProvider('redis://localhost:6379');
|
||||||
|
final cache = Cache(cacheProvider);
|
||||||
|
|
||||||
|
/// Create a sub-cache using a prefix, and apply a codec to store utf8
|
||||||
|
final userCache = cache.withPrefix('users').withCodec(utf8);
|
||||||
|
|
||||||
|
/// Get data form cache
|
||||||
|
String userinfo = await userCache['peter-pan'].get();
|
||||||
|
print(userinfo);
|
||||||
|
|
||||||
|
/// Put data into cache
|
||||||
|
await userCache['winnie'].set('Like honey');
|
||||||
|
|
||||||
|
await cacheProvider.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Development
|
||||||
|
To test the redis `CacheProvider` a redis instance must be running on
|
||||||
|
`localhost:6379`, this can be setup with:
|
||||||
|
|
||||||
|
* `docker run --rm -p 127.0.0.1:6379:6379 redis`
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
include: package:pedantic/analysis_options.yaml
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright 2019 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'neat_cache.dart' show Cache;
|
||||||
|
|
||||||
|
/// Low-level interface for a cache.
|
||||||
|
///
|
||||||
|
/// This can be an in-memory cache, something that writes to disk or to an
|
||||||
|
/// cache service such as memcached or redis.
|
||||||
|
///
|
||||||
|
/// The [Cache] provided by `package:neat_cache`, is intended to wrap
|
||||||
|
/// a [CacheProvider] and provide a more convinient high-level interface.
|
||||||
|
///
|
||||||
|
/// Implementers of [CacheProvider] can implement something that stores a value
|
||||||
|
/// of any type `T`, but usually implementers should aim to implement
|
||||||
|
/// `CacheProvider<List<int>>` which stores binary data.
|
||||||
|
///
|
||||||
|
/// Implementations of the [CacheProvider] interface using a remote backing
|
||||||
|
/// store should throw [IntermittentCacheException] when an intermittent network
|
||||||
|
/// issue occurs. The [CacheProvider] should obviously attempt to reconnect to
|
||||||
|
/// the remote backing store, but it should not retry operations.
|
||||||
|
///
|
||||||
|
/// Operations will be retried by [Cache], if necessary. Many use-cases of
|
||||||
|
/// caching are resilient to intermittent failures.
|
||||||
|
abstract class CacheProvider<T> {
|
||||||
|
/// Fetch data stored under [key].
|
||||||
|
///
|
||||||
|
/// If nothing is cached for [key], this **must** return `null`.
|
||||||
|
Future<T?> get(String key);
|
||||||
|
|
||||||
|
/// Set [value] stored at [key] with optional [ttl].
|
||||||
|
///
|
||||||
|
/// If a value is already stored at [key], that value should be overwritten
|
||||||
|
/// with the new [value] given here.
|
||||||
|
///
|
||||||
|
/// When given [ttl] is advisory, however, implementers should avoid returning
|
||||||
|
/// entries that are far past their [ttl].
|
||||||
|
Future<void> set(String key, T value, [Duration? ttl]);
|
||||||
|
|
||||||
|
/// Clear value stored at [key].
|
||||||
|
///
|
||||||
|
/// After this has returned future calls to [get] for the given [key] should
|
||||||
|
/// not return any value, unless a new value have been set.
|
||||||
|
Future<void> purge(String key);
|
||||||
|
|
||||||
|
/// Close all connections, causing all future operations to throw.
|
||||||
|
///
|
||||||
|
/// This method frees resources used by this [CacheProvider], if backed by
|
||||||
|
/// a remote service like redis, this should close the connection.
|
||||||
|
///
|
||||||
|
/// Calling [close] multiple times does not throw. But after this has returned
|
||||||
|
/// all future operations should throw [StateError].
|
||||||
|
Future<void> close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown when there is an intermittent exception.
|
||||||
|
///
|
||||||
|
/// This is typically thrown if there is an intermittent connection error.
|
||||||
|
class IntermittentCacheException implements Exception {
|
||||||
|
final String _message;
|
||||||
|
IntermittentCacheException(this._message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _message;
|
||||||
|
}
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
// Copyright 2019 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:convert/convert.dart' show IdentityCodec;
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:retry/retry.dart';
|
||||||
|
|
||||||
|
import 'cache_provider.dart';
|
||||||
|
import 'src/providers/inmemory.dart';
|
||||||
|
import 'src/providers/redis.dart';
|
||||||
|
|
||||||
|
final _logger = Logger('neat_cache');
|
||||||
|
|
||||||
|
/// Cache for objects of type [T], wrapping a [CacheProvider] to provide a
|
||||||
|
/// high-level interface.
|
||||||
|
///
|
||||||
|
/// Cache entries are accessed using the indexing operator `[]`, this returns a
|
||||||
|
/// [Entry<T>] wrapper that can be used to get/set data cached at given key.
|
||||||
|
///
|
||||||
|
/// **Example**
|
||||||
|
/// ```dart
|
||||||
|
/// final Cache<List<int>> cache = Cache.inMemoryCache(4096);
|
||||||
|
///
|
||||||
|
/// // Write data to cache
|
||||||
|
/// await cache['cached-zeros'].set([0, 0, 0, 0]);
|
||||||
|
///
|
||||||
|
/// // Read data from cache
|
||||||
|
/// var r = await cache['cached-zeros'].get();
|
||||||
|
/// expect(r, equals([0, 0, 0, 0]));
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// A [Cache] can be _fused_ with a [Codec] using [withCodec] to get a cache
|
||||||
|
/// that stores a different kind of objects. It is also possible to create
|
||||||
|
/// a chuild cache using [withPrefix], such that all entries in the child
|
||||||
|
/// cache have a given prefix.
|
||||||
|
abstract class Cache<T> {
|
||||||
|
/// Get [Entry] wrapping data cached at [key].
|
||||||
|
Entry<T> operator [](String key);
|
||||||
|
|
||||||
|
/// Get a [Cache] wrapping of this cache with given [prefix].
|
||||||
|
Cache<T> withPrefix(String prefix);
|
||||||
|
|
||||||
|
/// Get a [Cache] wrapping of this cache by encoding objects of type [S] as
|
||||||
|
/// [T] using the given [codec].
|
||||||
|
Cache<S> withCodec<S>(Codec<S, T> codec);
|
||||||
|
|
||||||
|
/// Get a [Cache] wrapping of this cache with given [ttl] as default for all
|
||||||
|
/// entries being set using [Entry.set].
|
||||||
|
///
|
||||||
|
/// This only specifies a different default [ttl], to be used when [Entry.set]
|
||||||
|
/// is called without a [ttl] parameter.
|
||||||
|
Cache<T> withTTL(Duration ttl);
|
||||||
|
|
||||||
|
/// Create a [Cache] wrapping a [CacheProvider].
|
||||||
|
factory Cache(CacheProvider<T> provider) {
|
||||||
|
return _Cache<T, T>(provider, '', IdentityCodec());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an in-memory [CacheProvider] holding a maximum of [maxSize] cache
|
||||||
|
/// entries.
|
||||||
|
static CacheProvider<List<int>> inMemoryCacheProvider(int maxSize) {
|
||||||
|
return InMemoryCacheProvider(maxSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a redis [CacheProvider] by connecting using a [connectionString] on
|
||||||
|
/// the form `redis://<host>:<port>`.
|
||||||
|
static CacheProvider<List<int>> redisCacheProvider(Uri connectionString, {Duration commandTimeLimit = const Duration(milliseconds: 200)}) {
|
||||||
|
return RedisCacheProvider(connectionString, commandTimeLimit: commandTimeLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pointer to a location in the cache.
|
||||||
|
///
|
||||||
|
/// This simply wraps a cache key, such that you don't need to supply a cache
|
||||||
|
/// key for [get], [set] and [purge] operations.
|
||||||
|
abstract class Entry<T> {
|
||||||
|
/// Get value stored in this cache entry.
|
||||||
|
///
|
||||||
|
/// If used without [create], this function simply gets the value or `null` if
|
||||||
|
/// no value is stored.
|
||||||
|
///
|
||||||
|
/// If used with [create], this function becomes an upsert, returning the
|
||||||
|
/// value stored if any, otherwise creating a new value and storing it with
|
||||||
|
/// optional [ttl]. If multiple callers are using the same cache this is an
|
||||||
|
/// inherently racy operation, that is multiple instances of the value may
|
||||||
|
/// be created.
|
||||||
|
///
|
||||||
|
/// The [get] method is a best-effort method. In case of intermittent failures
|
||||||
|
/// from the underlying [CacheProvider] the [get] method will ignore failures
|
||||||
|
/// and return `null` (or result from [create] if specified).
|
||||||
|
Future<T?> get([Future<T?> Function() create, Duration ttl]);
|
||||||
|
|
||||||
|
/// Set the value stored in this cache entry.
|
||||||
|
///
|
||||||
|
/// If given [ttl] specifies the time-to-live. Notice that this is advisatory,
|
||||||
|
/// the underlying [CacheProvider] may choose to evit cache entries at any
|
||||||
|
/// time. However, it can be assumed that entries will not live far past
|
||||||
|
/// their [ttl].
|
||||||
|
///
|
||||||
|
/// The [set] method is a best-effort method. In case of intermittent failures
|
||||||
|
/// from the underlying [CacheProvider] the [set] method will ignore failures.
|
||||||
|
///
|
||||||
|
/// To ensure that cache entries are purged, use the [purge] method with
|
||||||
|
/// `retries` not set to zero.
|
||||||
|
Future<T?> set(T? value, [Duration ttl]);
|
||||||
|
|
||||||
|
/// Clear the value stored in this cache entry.
|
||||||
|
///
|
||||||
|
/// If [retries] is `0` (default), this is a best-effort method, which will
|
||||||
|
/// ignore intermittent failures. If [retries] is non-zero the operation will
|
||||||
|
/// be retried with exponential back-off, and [IntermittentCacheException]
|
||||||
|
/// will be thrown if all retries fails.
|
||||||
|
Future purge({int retries = 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Cache<T, V> implements Cache<T> {
|
||||||
|
final CacheProvider<V> _provider;
|
||||||
|
final String _prefix;
|
||||||
|
final Codec<T, V> _codec;
|
||||||
|
final Duration? _ttl;
|
||||||
|
|
||||||
|
_Cache(this._provider, this._prefix, this._codec, [this._ttl]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Entry<T> operator [](String key) => _Entry(this, _prefix + key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Cache<T> withPrefix(String prefix) =>
|
||||||
|
_Cache(_provider, _prefix + prefix, _codec, _ttl);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Cache<S> withCodec<S>(Codec<S, T> codec) =>
|
||||||
|
_Cache(_provider, _prefix, codec.fuse(_codec), _ttl);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Cache<T> withTTL(Duration ttl) => _Cache(_provider, _prefix, _codec, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Entry<T, V> implements Entry<T> {
|
||||||
|
final _Cache<T, V> _owner;
|
||||||
|
final String _key;
|
||||||
|
_Entry(this._owner, this._key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> get([Future<T?> Function()? create, Duration? ttl]) async {
|
||||||
|
V? value;
|
||||||
|
try {
|
||||||
|
_logger.finest(() => 'reading cache entry for "$_key"');
|
||||||
|
value = await _owner._provider.get(_key);
|
||||||
|
} on IntermittentCacheException {
|
||||||
|
_logger.fine(
|
||||||
|
// embedding [intermittent-cache-failure] to allow for easy log metrics
|
||||||
|
'[intermittent-cache-failure], failed to get cache entry for "$_key"',
|
||||||
|
);
|
||||||
|
value = null;
|
||||||
|
}
|
||||||
|
if (value == null) {
|
||||||
|
if (create == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final created = await create();
|
||||||
|
if (created != null) {
|
||||||
|
// Calling `set(null)` is equivalent to `purge()`, we can skip that here
|
||||||
|
await set(created, ttl);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
return _owner._codec.decode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> set(T? value, [Duration? ttl]) async {
|
||||||
|
if (value == null) {
|
||||||
|
await purge();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ttl ??= _owner._ttl;
|
||||||
|
final raw = _owner._codec.encode(value);
|
||||||
|
try {
|
||||||
|
await _owner._provider.set(_key, raw, ttl);
|
||||||
|
} on IntermittentCacheException {
|
||||||
|
_logger.fine(
|
||||||
|
// embedding [intermittent-cache-failure] to allow for easy log metrics
|
||||||
|
'[intermittent-cache-failure], failed to set cache entry for "$_key"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> purge({int retries = 0}) async {
|
||||||
|
// Common path is that we have no retries
|
||||||
|
if (retries == 0) {
|
||||||
|
try {
|
||||||
|
await _owner._provider.purge(_key);
|
||||||
|
} on IntermittentCacheException {
|
||||||
|
_logger.fine(
|
||||||
|
// embedding [intermittent-cache-failure] to allow for easy log metrics
|
||||||
|
'[intermittent-cache-failure], failed to purge cache entry for "$_key"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Test that we have a positive number of retries.
|
||||||
|
if (retries < 0) {
|
||||||
|
ArgumentError.value(retries, 'retries', 'retries < 0 is not allowed');
|
||||||
|
}
|
||||||
|
return await retry(
|
||||||
|
() => _owner._provider.purge(_key),
|
||||||
|
retryIf: (e) => e is IntermittentCacheException,
|
||||||
|
maxAttempts: 1 + retries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright 2019 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import '../../cache_provider.dart';
|
||||||
|
|
||||||
|
class _InMemoryEntry<T> {
|
||||||
|
final T value;
|
||||||
|
final DateTime? _expires;
|
||||||
|
_InMemoryEntry(this.value, [this._expires]);
|
||||||
|
bool get isExpired => _expires != null && _expires!.isBefore(DateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple two-generational LRU cache inspired by:
|
||||||
|
/// https://github.com/sindresorhus/quick-lru
|
||||||
|
class InMemoryCacheProvider<T> extends CacheProvider<T> {
|
||||||
|
/// New generation of cache entries.
|
||||||
|
Map<String, _InMemoryEntry<T>> _new = <String, _InMemoryEntry<T>>{};
|
||||||
|
|
||||||
|
/// Old generation of cache entries.
|
||||||
|
Map<String, _InMemoryEntry<T>> _old = <String, _InMemoryEntry<T>>{};
|
||||||
|
|
||||||
|
/// Maximum size before clearing old generation.
|
||||||
|
final int _maxSize;
|
||||||
|
|
||||||
|
/// Have this been closed.
|
||||||
|
bool _isClosed = false;
|
||||||
|
|
||||||
|
InMemoryCacheProvider(this._maxSize);
|
||||||
|
|
||||||
|
/// Clear old generation, if _maxSize have been reached.
|
||||||
|
void _maintainGenerations() {
|
||||||
|
if (_new.length >= _maxSize) {
|
||||||
|
_old = _new;
|
||||||
|
_new = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> get(String key) async {
|
||||||
|
if (_isClosed) {
|
||||||
|
throw StateError('CacheProvider.close() have been called');
|
||||||
|
}
|
||||||
|
// Lookup in the new generation
|
||||||
|
var entry = _new[key];
|
||||||
|
if (entry != null) {
|
||||||
|
if (!entry.isExpired) {
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
// Remove, if expired
|
||||||
|
_new.remove(key);
|
||||||
|
}
|
||||||
|
// Lookup in the old generation
|
||||||
|
entry = _old[key];
|
||||||
|
if (entry != null) {
|
||||||
|
if (!entry.isExpired) {
|
||||||
|
// If not expired, we insert the entry into the new generation
|
||||||
|
_new[key] = entry;
|
||||||
|
_maintainGenerations();
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
// Remove, if expired
|
||||||
|
_old.remove(key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> set(String key, T value, [Duration? ttl]) async {
|
||||||
|
if (_isClosed) {
|
||||||
|
throw StateError('CacheProvider.close() have been called');
|
||||||
|
}
|
||||||
|
if (ttl == null) {
|
||||||
|
_new[key] = _InMemoryEntry(value);
|
||||||
|
} else {
|
||||||
|
_new[key] = _InMemoryEntry(value, DateTime.now().add(ttl));
|
||||||
|
}
|
||||||
|
// Always remove key from old generation to avoid risks of looking up there
|
||||||
|
// if it's overwritten by an entry with a shorter ttl
|
||||||
|
_old.remove(key);
|
||||||
|
_maintainGenerations();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> purge(String key) async {
|
||||||
|
if (_isClosed) {
|
||||||
|
throw StateError('CacheProvider.close() have been called');
|
||||||
|
}
|
||||||
|
_new.remove(key);
|
||||||
|
_old.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
_isClosed = true;
|
||||||
|
_old = {};
|
||||||
|
_new = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// Copyright 2019 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io' show IOException, Socket, SocketOption;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'resp.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import '../../cache_provider.dart';
|
||||||
|
|
||||||
|
final _log = Logger('neat_cache');
|
||||||
|
|
||||||
|
class _RedisContext {
|
||||||
|
final RespClient client;
|
||||||
|
_RedisContext({
|
||||||
|
required this.client,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class RedisCacheProvider extends CacheProvider<List<int>> {
|
||||||
|
final Uri _connectionString;
|
||||||
|
final Duration _connectTimeLimit;
|
||||||
|
final Duration _commandTimeLimit;
|
||||||
|
final Duration _reconnectDelay;
|
||||||
|
|
||||||
|
Future<_RedisContext>? _context;
|
||||||
|
bool _isClosed = false;
|
||||||
|
|
||||||
|
RedisCacheProvider(
|
||||||
|
Uri connectionString, {
|
||||||
|
Duration connectTimeLimit = const Duration(seconds: 30),
|
||||||
|
Duration commandTimeLimit = const Duration(milliseconds: 200),
|
||||||
|
Duration reconnectDelay = const Duration(seconds: 30),
|
||||||
|
}) : _connectionString = connectionString,
|
||||||
|
_connectTimeLimit = connectTimeLimit,
|
||||||
|
_commandTimeLimit = commandTimeLimit,
|
||||||
|
_reconnectDelay = reconnectDelay {
|
||||||
|
if (!connectionString.isScheme('redis')) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
connectionString, 'connectionString', 'must have scheme redis://');
|
||||||
|
}
|
||||||
|
if (!connectionString.hasEmptyPath) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
connectionString, 'connectionString', 'cannot have a path');
|
||||||
|
}
|
||||||
|
if (connectTimeLimit.isNegative) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
connectTimeLimit, 'connectTimeLimit', 'must be positive');
|
||||||
|
}
|
||||||
|
if (commandTimeLimit.isNegative) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
commandTimeLimit, 'commandTimeLimit', 'must be positive');
|
||||||
|
}
|
||||||
|
if (reconnectDelay.isNegative) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
reconnectDelay, 'reconnectDelay', 'must be positive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_RedisContext> _createContext() async {
|
||||||
|
try {
|
||||||
|
_log.info('Connecting to redis');
|
||||||
|
final socket = await Socket.connect(
|
||||||
|
_connectionString.host,
|
||||||
|
_connectionString.port,
|
||||||
|
).timeout(_connectTimeLimit);
|
||||||
|
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||||
|
|
||||||
|
|
||||||
|
var client = RespClient(socket, socket);
|
||||||
|
if (_connectionString.userInfo.isNotEmpty) {
|
||||||
|
await client.command(['AUTH', _connectionString.userInfo]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
return _RedisContext(
|
||||||
|
client: client,
|
||||||
|
);
|
||||||
|
} on RedisConnectionException {
|
||||||
|
throw IntermittentCacheException('connection failed');
|
||||||
|
} on TimeoutException {
|
||||||
|
throw IntermittentCacheException('connect failed with timeout');
|
||||||
|
} on IOException catch (e) {
|
||||||
|
throw IntermittentCacheException('connect failed with IOException: $e');
|
||||||
|
} on Exception {
|
||||||
|
throw IntermittentCacheException('connect failed with exception');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_RedisContext> _getContext() {
|
||||||
|
if (_context != null) {
|
||||||
|
return _context!;
|
||||||
|
}
|
||||||
|
_context = _createContext();
|
||||||
|
scheduleMicrotask(() async {
|
||||||
|
_RedisContext ctx;
|
||||||
|
try {
|
||||||
|
ctx = await _context!;
|
||||||
|
} on IntermittentCacheException {
|
||||||
|
// If connecting fails, then we sleep and try again
|
||||||
|
await Future.delayed(_reconnectDelay);
|
||||||
|
_context = null; // reset _context, so next operation creates a new
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
_log.shout('unknown error/exception connecting to redis', e);
|
||||||
|
_context = null; // reset _context, so next operation creates a new
|
||||||
|
rethrow; // propagate the error to crash to application.
|
||||||
|
}
|
||||||
|
// If connecting was successful, then we await the connection being
|
||||||
|
// closed or error, and we reset _context.
|
||||||
|
try {
|
||||||
|
await ctx.client.closed;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore error
|
||||||
|
}
|
||||||
|
_context = null;
|
||||||
|
});
|
||||||
|
return _context!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _withResp<T>(Future<T> Function(RespClient) fn) async {
|
||||||
|
if (_isClosed) {
|
||||||
|
throw StateError('CacheProvider.closed() has been called');
|
||||||
|
}
|
||||||
|
final ctx = await _getContext();
|
||||||
|
try {
|
||||||
|
return await fn(ctx.client).timeout(_commandTimeLimit);
|
||||||
|
} on RedisCommandException catch (e) {
|
||||||
|
throw AssertionError('error from redis command: $e');
|
||||||
|
} on TimeoutException {
|
||||||
|
// If we had a timeout, doing the command we forcibly disconnect
|
||||||
|
// from the server, such that next retry will use a new connection.
|
||||||
|
await ctx.client.close(force: true);
|
||||||
|
throw IntermittentCacheException('redis command timeout');
|
||||||
|
} on RedisConnectionException catch (e) {
|
||||||
|
throw IntermittentCacheException('redis error: $e');
|
||||||
|
} on IOException catch (e) {
|
||||||
|
throw IntermittentCacheException('socket broken: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
_isClosed = true;
|
||||||
|
if (_context != null) {
|
||||||
|
try {
|
||||||
|
final ctx = await _context!;
|
||||||
|
await ctx.client.close();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<int>?> get(String key) => _withResp((client) async {
|
||||||
|
final r = await client.command(['GET', key]).catchError((e) {
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
if (r == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (r is Uint8List) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
assert(false, 'unexpected response from redis server');
|
||||||
|
|
||||||
|
// Force close the client
|
||||||
|
scheduleMicrotask(() => client.close(force: true));
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> set(String key, List<int> value, [Duration? ttl]) =>
|
||||||
|
_withResp((client) async {
|
||||||
|
final r = await client.command([
|
||||||
|
'SET',
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
if (ttl != null) ...<Object>['EX', ttl.inSeconds],
|
||||||
|
]);
|
||||||
|
if (r != 'OK') {
|
||||||
|
assert(false, 'unexpected response from redis server');
|
||||||
|
|
||||||
|
// Force close the client
|
||||||
|
scheduleMicrotask(() => client.close(force: true));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> purge(String key) => _withResp((client) async {
|
||||||
|
final r = await client.command(['DEL', key]);
|
||||||
|
if (r is! int) {
|
||||||
|
assert(false, 'unexpected response from redis server');
|
||||||
|
|
||||||
|
// Force close the client
|
||||||
|
scheduleMicrotask(() => client.close(force: true));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
// Copyright 2021 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
/// Minimalistic [RESP[1] protocol implementation in Dart.
|
||||||
|
///
|
||||||
|
/// [1]: https://redis.io/topics/protocol
|
||||||
|
library resp;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('neat_cache:redis');
|
||||||
|
|
||||||
|
/// Thrown when the server returns an error in response to a command.
|
||||||
|
class RedisCommandException implements Exception {
|
||||||
|
final String type;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
RedisCommandException._(this.type, this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'RedisCommandException: $type $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown if the redis connection is broken.
|
||||||
|
///
|
||||||
|
/// This typically happens if the connection is unexpectedly closed, the
|
||||||
|
/// [RESP protocol][1] is violated, or there is an internal error.
|
||||||
|
///
|
||||||
|
/// [1]: https://redis.io/topics/protocol
|
||||||
|
class RedisConnectionException implements Exception {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
RedisConnectionException._(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'RedisConnectionException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client implementing the [RESP protocol][1].
|
||||||
|
///
|
||||||
|
/// [1]: https://redis.io/topics/protocol
|
||||||
|
class RespClient {
|
||||||
|
static final _newLine = ascii.encode('\r\n');
|
||||||
|
|
||||||
|
/// No value in redis can be more than [512 MB][1].
|
||||||
|
///
|
||||||
|
/// [1]: https://redis.io/topics/data-types
|
||||||
|
static const _maxValueSize = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
final _ByteStreamScanner _input;
|
||||||
|
final StreamSink<List<int>> _output;
|
||||||
|
Future _pendingStream = Future.value(null);
|
||||||
|
|
||||||
|
final _pending = Queue<Completer<Object?>>();
|
||||||
|
|
||||||
|
bool _closing = false;
|
||||||
|
final _closed = Completer<void>();
|
||||||
|
|
||||||
|
/// Creates an instance of [RespClient] given an [input]stream and an [output]
|
||||||
|
/// sink.
|
||||||
|
///
|
||||||
|
/// If connecting over TCP as usual the `Socket` object from `dart:io`
|
||||||
|
/// implements both [Stream<Uint8List>] and [StreamSink<List<int>>]. Thus,
|
||||||
|
/// the following example is a reasonable way to make a client:
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// // Connect to redis server
|
||||||
|
/// final socket = await Socket.connect(host, port);
|
||||||
|
/// socket.setOption(SocketOption.tcpNoDelay, true);
|
||||||
|
///
|
||||||
|
/// // Create client
|
||||||
|
/// final client = RespClient(socket, socket);
|
||||||
|
/// ```
|
||||||
|
RespClient(
|
||||||
|
Stream<Uint8List> input,
|
||||||
|
StreamSink<List<int>> output,
|
||||||
|
) : _input = _ByteStreamScanner(input),
|
||||||
|
_output = output {
|
||||||
|
scheduleMicrotask(_readInput);
|
||||||
|
scheduleMicrotask(() async {
|
||||||
|
try {
|
||||||
|
await _output.done;
|
||||||
|
} catch (e, st) {
|
||||||
|
if (!_closing) {
|
||||||
|
return await _abort(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_closing) {
|
||||||
|
await _abort(
|
||||||
|
RedisConnectionException._('outgoing connection closed'),
|
||||||
|
StackTrace.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [Future] that is resolved when the connection is closed.
|
||||||
|
Future<void> get closed => _closed.future;
|
||||||
|
|
||||||
|
/// Send command to redis and return the result.
|
||||||
|
///
|
||||||
|
/// The [args] is a list of:
|
||||||
|
/// * [String],
|
||||||
|
/// * [List<int>], and,
|
||||||
|
/// * [int].
|
||||||
|
/// This is always encoded as an _RESP Array_ of _RESP Bulk Strings_.
|
||||||
|
///
|
||||||
|
/// Response will decoded as follows:
|
||||||
|
/// * RESP Simple String: returns [String],
|
||||||
|
/// * RESP Error: throws [RedisCommandException],
|
||||||
|
/// * RESP Integer: returns [int],
|
||||||
|
/// * RESP Bulk String: returns [Uint8List],
|
||||||
|
/// * RESP nil Bulk String: returns `null`,
|
||||||
|
/// * RESP Array: returns [List<Object?>], and,
|
||||||
|
/// * RESP nil Arrray: returns `null`.
|
||||||
|
///
|
||||||
|
/// Throws [RedisConnectionException] if underlying connection as been broken
|
||||||
|
/// or if the [RESP protocol][1] has been violated. After this, the client
|
||||||
|
/// should not be used further.
|
||||||
|
///
|
||||||
|
/// Forwards any [Exception] thrown by the underlying connection and aborts
|
||||||
|
/// the [RespClient]. Once aborted [closed] will be resolved, and further
|
||||||
|
/// attempts to call [command] will throw [RedisConnectionException].
|
||||||
|
///
|
||||||
|
/// Consumers are encouraged to handle [RedisConnectionException] and
|
||||||
|
/// reconnect, creating a new [RespClient], when [RedisConnectionException] is
|
||||||
|
/// encountered.
|
||||||
|
///
|
||||||
|
/// [1]: https://redis.io/topics/protocol
|
||||||
|
Future<Object?> command(List<Object> args) async {
|
||||||
|
if (_closing) {
|
||||||
|
throw RedisConnectionException._('redis connection is closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
out.addByte('*'.codeUnitAt(0));
|
||||||
|
out.add(ascii.encode(args.length.toString()));
|
||||||
|
out.add(_newLine);
|
||||||
|
for (final arg in args) {
|
||||||
|
List<int> bytes;
|
||||||
|
if (arg is String) {
|
||||||
|
bytes = utf8.encode(arg);
|
||||||
|
} else if (arg is List<int>) {
|
||||||
|
bytes = arg;
|
||||||
|
} else if (arg is int) {
|
||||||
|
bytes = ascii.encode(arg.toString());
|
||||||
|
} else {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
args,
|
||||||
|
'args',
|
||||||
|
'arguments for redis must be String, List<int>, int',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.addByte(r'$'.codeUnitAt(0));
|
||||||
|
out.add(ascii.encode(bytes.length.toString()));
|
||||||
|
out.add(_newLine);
|
||||||
|
out.add(bytes);
|
||||||
|
out.add(_newLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
final c = Completer<Object?>();
|
||||||
|
_pending.addLast(c);
|
||||||
|
try {
|
||||||
|
_pendingStream = _pendingStream
|
||||||
|
.then((value) => _output.addStream(Stream.value(out.toBytes())));
|
||||||
|
} on Exception catch (e, st) {
|
||||||
|
await _abort(e, st);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await c.future;
|
||||||
|
} on RedisCommandException catch (e) {
|
||||||
|
// Don't use rethrow because the stack-trace really should start here.
|
||||||
|
// we always throw RedisCommandException with a StackTrace.empty, because
|
||||||
|
// it's a thing that happens on the server, and that stack-trace of the
|
||||||
|
// code that reads the value from the server is uninteresting.
|
||||||
|
throw e; // ignore: use_rethrow_when_possible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send `QUIT` command to redis and close the connection.
|
||||||
|
///
|
||||||
|
/// If [force] is `true`, then the connection will be forcibly closed
|
||||||
|
/// immediately. Otherwise, connection will reject new commands, but wait for
|
||||||
|
/// existing commands to complete.
|
||||||
|
Future<void> close({bool force = false}) async {
|
||||||
|
_closing = true;
|
||||||
|
|
||||||
|
if (!_closing) {
|
||||||
|
// Always send QUIT message to be nice
|
||||||
|
try {
|
||||||
|
final quit = command(['QUIT']);
|
||||||
|
scheduleMicrotask(() async {
|
||||||
|
await quit.catchError((_) {/* ignore */});
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
scheduleMicrotask(() async {
|
||||||
|
await _output.close().catchError((_) {/* ignore */});
|
||||||
|
});
|
||||||
|
await _closed.future;
|
||||||
|
} else {
|
||||||
|
await _output.close().catchError((_) {/* ignore */});
|
||||||
|
|
||||||
|
// Resolve all outstanding requests
|
||||||
|
final pending = _pending.toList(growable: false);
|
||||||
|
_pending.clear();
|
||||||
|
final e = RedisConnectionException._('redis client forcibly closed');
|
||||||
|
final st = StackTrace.current;
|
||||||
|
pending.forEach((c) => c.completeError(e, st));
|
||||||
|
}
|
||||||
|
await _input.cancel();
|
||||||
|
|
||||||
|
assert(_pending.isEmpty, 'new pending requests added after close()');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abort due to internal error
|
||||||
|
Future<void> _abort(Object e, StackTrace st) async {
|
||||||
|
if (!_closing) {
|
||||||
|
_log.warning('redis connection broken:', e, st);
|
||||||
|
}
|
||||||
|
|
||||||
|
_closing = true;
|
||||||
|
|
||||||
|
// Resolve all outstanding requests
|
||||||
|
final pending = _pending.toList(growable: false);
|
||||||
|
_pending.clear();
|
||||||
|
scheduleMicrotask(() {
|
||||||
|
pending.forEach((c) => c.completeError(e, st));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!_closed.isCompleted) {
|
||||||
|
_closed.complete();
|
||||||
|
}
|
||||||
|
await _input.cancel();
|
||||||
|
|
||||||
|
assert(_pending.isEmpty, 'new pending requests added after aborting');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _readInput() async {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
Object? value;
|
||||||
|
try {
|
||||||
|
value = await _readValue();
|
||||||
|
} on RedisConnectionException catch (e, st) {
|
||||||
|
return await _abort(e, st);
|
||||||
|
}
|
||||||
|
if (_pending.isEmpty) {
|
||||||
|
return await _abort(
|
||||||
|
RedisConnectionException._('unexpected value from server'),
|
||||||
|
StackTrace.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final c = _pending.removeFirst();
|
||||||
|
if (value is RedisCommandException) {
|
||||||
|
// This is an error code returned by the server, it doesn't have a
|
||||||
|
// stack-trace!
|
||||||
|
c.completeError(value, StackTrace.empty);
|
||||||
|
} else {
|
||||||
|
c.complete(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_closing && _pending.isEmpty) {
|
||||||
|
return _closed.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
_log.shout('internal redis client error:', e, st);
|
||||||
|
await _abort(
|
||||||
|
RedisConnectionException._('internal redis client error: $e'),
|
||||||
|
st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _whitespacePattern = RegExp(r'\s');
|
||||||
|
|
||||||
|
Future<Object?> _readValue() async {
|
||||||
|
Uint8List line;
|
||||||
|
try {
|
||||||
|
line = await _input.readLine(maxSize: _maxValueSize);
|
||||||
|
} on Exception catch (e, st) {
|
||||||
|
await _abort(e, st);
|
||||||
|
throw RedisConnectionException._('exception reading line: $e');
|
||||||
|
}
|
||||||
|
if (line.isEmpty) {
|
||||||
|
throw RedisConnectionException._('Incoming stream from server closed');
|
||||||
|
}
|
||||||
|
if (!_endsWithNewLine(line)) {
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Invalid server message: missing newline',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final type = line[0];
|
||||||
|
final rest = Uint8List.sublistView(line, 1, line.length - 2);
|
||||||
|
|
||||||
|
// Handle simple strings
|
||||||
|
if (type == '+'.codeUnitAt(0)) {
|
||||||
|
try {
|
||||||
|
return utf8.decode(rest);
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Invalid simple string from server: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
if (type == '-'.codeUnitAt(0)) {
|
||||||
|
final message = utf8.decode(
|
||||||
|
rest,
|
||||||
|
allowMalformed: true,
|
||||||
|
);
|
||||||
|
final i = message.indexOf(_whitespacePattern);
|
||||||
|
if (i != -1 && i + 1 < message.length) {
|
||||||
|
return RedisCommandException._(
|
||||||
|
message.substring(0, i),
|
||||||
|
message.substring(i + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RedisCommandException._('ERR', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle integers
|
||||||
|
if (type == ':'.codeUnitAt(0)) {
|
||||||
|
int value;
|
||||||
|
try {
|
||||||
|
value = int.parse(ascii.decode(rest));
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Invalid integer from server: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value < 0) {
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Invalid integer from server: value < 0',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle bulk strings (binary blobs)
|
||||||
|
if (type == r'$'.codeUnitAt(0)) {
|
||||||
|
int length;
|
||||||
|
try {
|
||||||
|
length = int.parse(ascii.decode(rest));
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Invalid bulk string length from server: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (length == -1) {
|
||||||
|
return null; // Special case for nil value
|
||||||
|
}
|
||||||
|
if (length < 0 || length > _maxValueSize) {
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Invalid bulk string length from server: $length',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Uint8List bytes;
|
||||||
|
try {
|
||||||
|
bytes = await _input.readBytes(length + 2);
|
||||||
|
} on Exception catch (e, st) {
|
||||||
|
await _abort(e, st);
|
||||||
|
throw RedisConnectionException._('exception reading bytes: $e');
|
||||||
|
}
|
||||||
|
if (bytes.length != length + 2) {
|
||||||
|
throw RedisConnectionException._('Incoming stream from server closed');
|
||||||
|
}
|
||||||
|
if (!_endsWithNewLine(bytes)) {
|
||||||
|
throw RedisConnectionException._('Invalid bulk string from server');
|
||||||
|
}
|
||||||
|
return Uint8List.sublistView(bytes, 0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrays
|
||||||
|
if (type == '*'.codeUnitAt(0)) {
|
||||||
|
int length;
|
||||||
|
try {
|
||||||
|
length = int.parse(ascii.decode(rest));
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Invalid array length from server: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (length == -1) {
|
||||||
|
return null; // Special case for nil value
|
||||||
|
}
|
||||||
|
if (length < 0) {
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Invalid array length from server: $length',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final values = <Object?>[];
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
values.add(await _readValue());
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RedisConnectionException._(
|
||||||
|
'Unknown type from server: ${String.fromCharCode(type)}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _endsWithNewLine(Uint8List line) {
|
||||||
|
final N = line.length;
|
||||||
|
return (N >= 2 &&
|
||||||
|
line[N - 2] == '\r'.codeUnitAt(0) &&
|
||||||
|
line[N - 1] == '\n'.codeUnitAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An stream wrapper for reading line-by-line or reading N bytes.
|
||||||
|
class _ByteStreamScanner {
|
||||||
|
static final _emptyList = Uint8List.fromList([]);
|
||||||
|
|
||||||
|
final StreamIterator<Uint8List> _input;
|
||||||
|
Uint8List _buffer = _emptyList;
|
||||||
|
|
||||||
|
_ByteStreamScanner(Stream<Uint8List> stream)
|
||||||
|
: _input = StreamIterator(stream);
|
||||||
|
|
||||||
|
/// Read a single byte, return zero if stream has ended.
|
||||||
|
Future<int?> readByte() async {
|
||||||
|
final bytes = await readBytes(1);
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return bytes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read up to [size] bytes from stream, returns less than [size] bytes if
|
||||||
|
/// stream ends before [size] bytes are read.
|
||||||
|
Future<Uint8List> readBytes(int size) async {
|
||||||
|
RangeError.checkNotNegative(size, 'size');
|
||||||
|
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
while (size > 0) {
|
||||||
|
if (_buffer.isEmpty) {
|
||||||
|
if (!(await _input.moveNext())) {
|
||||||
|
// Don't attempt to read more data, as there is no more data.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_buffer = _input.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_buffer.isNotEmpty) {
|
||||||
|
if (size < _buffer.length) {
|
||||||
|
out.add(Uint8List.sublistView(_buffer, 0, size));
|
||||||
|
_buffer = Uint8List.sublistView(_buffer, size);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.add(_buffer);
|
||||||
|
size -= _buffer.length;
|
||||||
|
_buffer = _emptyList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read until the next `\n` inclusive.
|
||||||
|
///
|
||||||
|
/// Throws [RedisConnectionException] if [maxSize] is exceeded.
|
||||||
|
Future<Uint8List> readLine({int? maxSize}) async {
|
||||||
|
if (maxSize != null) {
|
||||||
|
RangeError.checkNotNegative(maxSize, 'maxSize');
|
||||||
|
}
|
||||||
|
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
while (true) {
|
||||||
|
if (_buffer.isEmpty) {
|
||||||
|
if (!(await _input.moveNext())) {
|
||||||
|
// Don't attempt to read more data, as there is no more data.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_buffer = _input.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_buffer.isNotEmpty) {
|
||||||
|
final i = _buffer.indexOf('\n'.codeUnitAt(0));
|
||||||
|
if (i != -1) {
|
||||||
|
out.add(Uint8List.sublistView(_buffer, 0, i + 1));
|
||||||
|
_buffer = Uint8List.sublistView(_buffer, i + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.add(_buffer);
|
||||||
|
_buffer = _emptyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxSize != null && out.length > maxSize) {
|
||||||
|
throw RedisConnectionException._('Line exceeds maxSize: $maxSize');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel underlying stream, ending it prematurely.
|
||||||
|
Future<void> cancel() async => await _input.cancel();
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
name: neat_cache
|
||||||
|
version: 2.0.1
|
||||||
|
description: |
|
||||||
|
A neat cache abstraction for wrapping in-memory or redis caches.
|
||||||
|
homepage: https://github.com/google/dart-neats/tree/master/neat_cache
|
||||||
|
repository: https://github.com/google/dart-neats.git
|
||||||
|
issue_tracker: https://github.com/google/dart-neats/labels/pkg:neat_cache
|
||||||
|
dependencies:
|
||||||
|
convert: ^3.0.0
|
||||||
|
logging: ^1.0.1
|
||||||
|
retry: ^3.0.0
|
||||||
|
async: ^2.7.0
|
||||||
|
meta: ^1.4.0
|
||||||
|
dev_dependencies:
|
||||||
|
test: ^1.5.1
|
||||||
|
pedantic: ^1.4.0
|
||||||
|
environment:
|
||||||
|
sdk: '>=2.12.0 <3.0.0'
|
||||||
@@ -4,3 +4,4 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
avoid_renaming_method_parameters: false
|
avoid_renaming_method_parameters: false
|
||||||
overridden_fields: false
|
overridden_fields: false
|
||||||
|
unnecessary_statements: true
|
||||||
|
|||||||
+34
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:angel3_migration/angel3_migration.dart';
|
||||||
|
import 'package:angel3_orm_postgres/angel3_orm_postgres.dart';
|
||||||
import 'package:dde_gesture_manager_api/src/config/plugins/orm.dart';
|
import 'package:dde_gesture_manager_api/src/config/plugins/orm.dart';
|
||||||
import 'package:dde_gesture_manager_api/models.dart';
|
import 'package:dde_gesture_manager_api/models.dart';
|
||||||
import 'package:angel3_configuration/angel3_configuration.dart';
|
import 'package:angel3_configuration/angel3_configuration.dart';
|
||||||
@@ -6,6 +8,8 @@ import 'package:angel3_migration_runner/postgres.dart';
|
|||||||
import 'package:file/local.dart';
|
import 'package:file/local.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
late Map configuration;
|
||||||
|
|
||||||
void main(List<String> args) async {
|
void main(List<String> args) async {
|
||||||
// Enable the logging
|
// Enable the logging
|
||||||
Logger.root.level = Level.INFO;
|
Logger.root.level = Level.INFO;
|
||||||
@@ -19,10 +23,39 @@ void main(List<String> args) async {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var fs = LocalFileSystem();
|
var fs = LocalFileSystem();
|
||||||
var configuration = await loadStandaloneConfiguration(fs);
|
configuration = await loadStandaloneConfiguration(fs);
|
||||||
var connection = await connectToPostgres(configuration);
|
var connection = await connectToPostgres(configuration);
|
||||||
var migrationRunner = PostgresMigrationRunner(connection, migrations: [
|
var migrationRunner = PostgresMigrationRunner(connection, migrations: [
|
||||||
UserMigration(),
|
UserMigration(),
|
||||||
|
UserSeed(),
|
||||||
]);
|
]);
|
||||||
await runMigrations(migrationRunner, args);
|
await runMigrations(migrationRunner, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class UserSeed extends Migration {
|
||||||
|
@override
|
||||||
|
void up(Schema schema) async {
|
||||||
|
await doUserSeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void down(Schema schema) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future doUserSeed() async {
|
||||||
|
var connection = await connectToPostgres(configuration);
|
||||||
|
await connection.open();
|
||||||
|
var executor = PostgreSqlExecutor(connection);
|
||||||
|
var userQuery = UserQuery();
|
||||||
|
userQuery.where?.email.equals('admin@admin.com');
|
||||||
|
var one = await userQuery.getOne(executor);
|
||||||
|
if (one.isEmpty) {
|
||||||
|
userQuery = UserQuery();
|
||||||
|
userQuery.values.copyFrom(User(
|
||||||
|
email: 'admin@admin.com',
|
||||||
|
password: '1234567890',
|
||||||
|
));
|
||||||
|
return userQuery.insert(executor).then((value) => connection.close());
|
||||||
|
}
|
||||||
|
return connection.close();
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ postgres:
|
|||||||
password: App1970#
|
password: App1970#
|
||||||
useSSL: false
|
useSSL: false
|
||||||
time_zone: UTC
|
time_zone: UTC
|
||||||
jwt_secret: "OvA9SBLnncot8gFHvt8Gh1qkQ1ptGIQW"
|
jwt_secret: "OvA9SBLnncot8gFHvt8Gh1qkQ1ptGIQW"
|
||||||
|
password_salt: "test"
|
||||||
+91
-3
@@ -1,8 +1,96 @@
|
|||||||
class Apis {
|
class Apis {
|
||||||
|
static const apiScheme = 'http';
|
||||||
|
static const apiHost = '127.0.0.1';
|
||||||
|
static const apiPort = 3000;
|
||||||
|
|
||||||
|
static const appNewVersionUrl = 'https://www.debuggerx.com';
|
||||||
|
|
||||||
static final system = SystemApis();
|
static final system = SystemApis();
|
||||||
|
static final auth = AuthApis();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthApis {
|
||||||
|
static final String path = '/auth';
|
||||||
|
|
||||||
|
String get loginOrSignup => [path, 'login_or_signup'].joinPath();
|
||||||
|
|
||||||
|
String confirmSignup({required StringParam accessKey}) => [path, 'confirm_sign_up', accessKey].joinPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
class SystemApis {
|
class SystemApis {
|
||||||
static final String _path = '/system';
|
static final String path = '/system';
|
||||||
String get appVersion => _path + '/app-version';
|
|
||||||
}
|
String get appVersion => [path, 'app-version'].joinPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
final _paramsMap = {
|
||||||
|
'IntParam': IntParam.nameOnRoute,
|
||||||
|
'DoubleParam': DoubleParam.nameOnRoute,
|
||||||
|
'StringParam': StringParam.nameOnRoute,
|
||||||
|
};
|
||||||
|
|
||||||
|
extension JoinPath on List {
|
||||||
|
joinPath() => join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RouteUrl on Function {
|
||||||
|
String get route {
|
||||||
|
var funStr = toString();
|
||||||
|
funStr = funStr.replaceAll(RegExp(r'.+\(\{'), ' ').replaceAll(RegExp(r'\}\).+'), ' ').replaceAll(' required ', '');
|
||||||
|
var parts = funStr.split(',');
|
||||||
|
Map<Symbol, dynamic> params = {};
|
||||||
|
for (var part in parts) {
|
||||||
|
var p = part.trim().split(' ');
|
||||||
|
params[Symbol(p.last)] = (_paramsMap[p.first] as Function).call(p.last);
|
||||||
|
}
|
||||||
|
return Function.apply(this, [], params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntParam {
|
||||||
|
final int val;
|
||||||
|
String? name;
|
||||||
|
|
||||||
|
IntParam(this.val);
|
||||||
|
|
||||||
|
IntParam.nameOnRoute(this.name) : val = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => name == null ? val.toString() : 'int:$name';
|
||||||
|
}
|
||||||
|
|
||||||
|
class DoubleParam {
|
||||||
|
final double val;
|
||||||
|
String? name;
|
||||||
|
|
||||||
|
DoubleParam(this.val);
|
||||||
|
|
||||||
|
DoubleParam.nameOnRoute(this.name) : val = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => name == null ? val.toString() : 'double:$name';
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringParam {
|
||||||
|
final String val;
|
||||||
|
String? name;
|
||||||
|
|
||||||
|
StringParam(this.val);
|
||||||
|
|
||||||
|
StringParam.nameOnRoute(this.name) : val = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => name == null ? val.toString() : ':$name';
|
||||||
|
}
|
||||||
|
|
||||||
|
extension IntParamExt on int {
|
||||||
|
IntParam get param => IntParam(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DoubleParamExt on double {
|
||||||
|
DoubleParam get param => DoubleParam(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StringParamExt on String {
|
||||||
|
StringParam get param => StringParam(this);
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
export 'src/models/user.dart';
|
export 'src/models/user.dart';
|
||||||
export 'src/models/app_version.dart';
|
export 'src/models/app_version.dart';
|
||||||
|
export 'src/models/login_success.dart';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:angel3_auth/angel3_auth.dart';
|
||||||
|
import 'package:angel3_framework/angel3_framework.dart';
|
||||||
|
import 'package:angel3_orm/angel3_orm.dart' as orm;
|
||||||
|
import 'package:dde_gesture_manager_api/models.dart';
|
||||||
|
|
||||||
|
Future<void> configureServer(Angel app) async {
|
||||||
|
var auth = AngelAuth<User>(
|
||||||
|
jwtKey: app.configuration['jwt_secret'],
|
||||||
|
allowCookie: false,
|
||||||
|
deserializer: (p) async => (UserQuery()..where!.id.equals(int.parse(p)))
|
||||||
|
.getOne(app.container!.make<orm.QueryExecutor>())
|
||||||
|
.then((value) => value.value),
|
||||||
|
serializer: (p) => p.id ?? '',
|
||||||
|
);
|
||||||
|
await auth.configureServer(app);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:angel3_framework/angel3_framework.dart';
|
import 'package:angel3_framework/angel3_framework.dart';
|
||||||
import 'orm.dart' as orm;
|
import 'orm.dart' as orm;
|
||||||
|
import 'jwt.dart' as jwt;
|
||||||
|
import 'redis_cache.dart' as redis_cache;
|
||||||
|
|
||||||
Future configureServer(Angel app) async {
|
Future configureServer(Angel app) async {
|
||||||
// Include any plugins you have made here.
|
// Include any plugins you have made here.
|
||||||
await app.configure(orm.configureServer);
|
await app.configure(orm.configureServer);
|
||||||
|
await app.configure(jwt.configureServer);
|
||||||
|
await app.configure(redis_cache.configureServer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:angel3_framework/angel3_framework.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:neat_cache/neat_cache.dart';
|
||||||
|
|
||||||
|
Future<void> configureServer(Angel app) async {
|
||||||
|
final _log = Logger('RedisPlugin');
|
||||||
|
|
||||||
|
if (app.container == null) {
|
||||||
|
_log.severe('Angel3 container is null');
|
||||||
|
throw StateError('Angel.container is null. All authentication will fail.');
|
||||||
|
}
|
||||||
|
var appContainer = app.container!;
|
||||||
|
final cache = RedisCache(app.configuration);
|
||||||
|
appContainer.registerSingleton(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
class RedisCache {
|
||||||
|
late Cache cache;
|
||||||
|
|
||||||
|
RedisCache(Map config) {
|
||||||
|
var redisConfig = config['redis'] as Map? ?? {};
|
||||||
|
|
||||||
|
final cacheProvider = Cache.redisCacheProvider(
|
||||||
|
Uri(
|
||||||
|
scheme: 'redis',
|
||||||
|
host: redisConfig['host'],
|
||||||
|
port: redisConfig['port'],
|
||||||
|
userInfo: redisConfig['password'],
|
||||||
|
),
|
||||||
|
commandTimeLimit: const Duration(seconds: 1),
|
||||||
|
);
|
||||||
|
cache = Cache(cacheProvider).withCodec(utf8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:angel3_serialize/angel3_serialize.dart';
|
||||||
|
|
||||||
|
part 'login_success.g.dart';
|
||||||
|
|
||||||
|
@serializable
|
||||||
|
class _LoginSuccess {
|
||||||
|
@SerializableField(isNullable: false)
|
||||||
|
String? token;
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:angel3_migration/angel3_migration.dart';
|
import 'package:angel3_migration/angel3_migration.dart';
|
||||||
import 'package:angel3_serialize/angel3_serialize.dart';
|
import 'package:angel3_serialize/angel3_serialize.dart';
|
||||||
import 'package:angel3_orm/angel3_orm.dart';
|
import 'package:angel3_orm/angel3_orm.dart';
|
||||||
import 'package:dde_gesture_manager_api/src/models/base_model.dart';
|
import 'package:dde_gesture_manager_api/src/models/base_model.dart';
|
||||||
import 'package:optional/optional.dart';
|
import 'package:optional/optional.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
part 'user.g.dart';
|
part 'user.g.dart';
|
||||||
|
|
||||||
@serializable
|
@serializable
|
||||||
@orm
|
@orm
|
||||||
abstract class _User extends BaseModel {
|
abstract class _User extends BaseModel {
|
||||||
|
@Column(isNullable: false, indexType: IndexType.unique)
|
||||||
@SerializableField(isNullable: false)
|
@SerializableField(isNullable: false)
|
||||||
String? get email;
|
String? get email;
|
||||||
|
|
||||||
@SerializableField(isNullable: false)
|
@Column(isNullable: false, length: 32)
|
||||||
|
@SerializableField(isNullable: true, exclude: true)
|
||||||
String? get password;
|
String? get password;
|
||||||
|
|
||||||
@SerializableField(isNullable: false)
|
String secret(String salt) => base64.encode(Hmac(sha256, salt.codeUnits).convert((password ?? '').codeUnits).bytes);
|
||||||
String? get token;
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:angel3_auth/angel3_auth.dart';
|
||||||
|
import 'package:angel3_framework/angel3_framework.dart';
|
||||||
|
import 'package:dde_gesture_manager_api/apis.dart';
|
||||||
|
import 'package:dde_gesture_manager_api/models.dart';
|
||||||
|
import 'package:mailer/mailer.dart';
|
||||||
|
import 'package:mailer/smtp_server.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import 'controller_extensions.dart';
|
||||||
|
|
||||||
|
Future configureServer(Angel app) async {
|
||||||
|
app.post(Apis.auth.loginOrSignup, (req, res) async {
|
||||||
|
var userParams = UserSerializer.fromMap(req.bodyAsMap);
|
||||||
|
userParams.password = req.bodyAsMap[UserFields.password];
|
||||||
|
var userQuery = UserQuery();
|
||||||
|
userQuery.where?.email.equals(userParams.email ?? '');
|
||||||
|
var user = await userQuery.getOne(req.queryExecutor);
|
||||||
|
|
||||||
|
if (user.isEmpty) {
|
||||||
|
String accessKey = Uuid().v1();
|
||||||
|
|
||||||
|
await req.cache
|
||||||
|
.withPrefix('sign_up:')[accessKey]
|
||||||
|
.set(json.encode({'email': userParams.email, 'password': userParams.password}), Duration(minutes: 30));
|
||||||
|
var smtpConfig = app.configuration['smtp'];
|
||||||
|
var smtpServer =
|
||||||
|
SmtpServer(smtpConfig['host'], ssl: true, username: smtpConfig['username'], password: smtpConfig['password']);
|
||||||
|
var message = Message()
|
||||||
|
..from = Address(smtpConfig['username'])
|
||||||
|
..recipients.add(userParams.email)
|
||||||
|
..subject = '确认注册'
|
||||||
|
..html = await app.viewGenerator!(
|
||||||
|
'confirm_sign_up.html',
|
||||||
|
{
|
||||||
|
"confirm_url": Uri(
|
||||||
|
scheme: Apis.apiScheme,
|
||||||
|
host: Apis.apiHost,
|
||||||
|
port: Apis.apiPort,
|
||||||
|
path: Apis.auth.confirmSignup(accessKey: accessKey.param),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
send(message, smtpServer);
|
||||||
|
return res.notFound();
|
||||||
|
} else if (user.value.password != userParams.password) {
|
||||||
|
return res.unauthorized();
|
||||||
|
} else {
|
||||||
|
var angelAuth = req.container!.make<AngelAuth>();
|
||||||
|
await angelAuth.loginById(user.value.id!, req, res);
|
||||||
|
var authToken = req.container!.make<AuthToken>();
|
||||||
|
authToken.payload[UserFields.password] = user.value.secret(app.configuration['password_salt']);
|
||||||
|
var serializedToken = authToken.serialize(angelAuth.hmac);
|
||||||
|
return res.json(LoginSuccess(token: serializedToken));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(Apis.auth.confirmSignup.route, (req, res) async {
|
||||||
|
var accessKey = req.params['accessKey'];
|
||||||
|
var cache = req.cache.withPrefix('sign_up:');
|
||||||
|
var signupInfo = await cache[accessKey].get();
|
||||||
|
if (signupInfo != null && signupInfo is String && signupInfo.isNotEmpty) {
|
||||||
|
var decodedSignupInfo = json.decode(signupInfo);
|
||||||
|
var userQuery = UserQuery();
|
||||||
|
userQuery.values.copyFrom(User(
|
||||||
|
email: decodedSignupInfo[UserFields.email],
|
||||||
|
password: decodedSignupInfo[UserFields.password],
|
||||||
|
));
|
||||||
|
await userQuery.insert(req.queryExecutor);
|
||||||
|
cache[accessKey].purge();
|
||||||
|
return res.render('sign_up_result.html', {'success': true});
|
||||||
|
}
|
||||||
|
return res.render('sign_up_result.html', {'success': false});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,12 +2,24 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:angel3_framework/angel3_framework.dart';
|
import 'package:angel3_framework/angel3_framework.dart';
|
||||||
import 'package:angel3_orm/angel3_orm.dart' as orm;
|
import 'package:angel3_orm/angel3_orm.dart' as orm;
|
||||||
|
import 'package:dde_gesture_manager_api/src/config/plugins/redis_cache.dart';
|
||||||
|
import 'package:neat_cache/neat_cache.dart';
|
||||||
|
|
||||||
extension ResponseNoContent on ResponseContext {
|
extension ResponseNoContent on ResponseContext {
|
||||||
noContent() {
|
noContent() {
|
||||||
statusCode = HttpStatus.noContent;
|
statusCode = HttpStatus.noContent;
|
||||||
return close();
|
return close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notFound() {
|
||||||
|
statusCode = HttpStatus.notFound;
|
||||||
|
return close();
|
||||||
|
}
|
||||||
|
|
||||||
|
unauthorized() {
|
||||||
|
statusCode = HttpStatus.unauthorized;
|
||||||
|
return close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension QueryWhereId on orm.Query {
|
extension QueryWhereId on orm.Query {
|
||||||
@@ -19,3 +31,7 @@ extension QueryWhereId on orm.Query {
|
|||||||
extension QueryExecutor on RequestContext {
|
extension QueryExecutor on RequestContext {
|
||||||
orm.QueryExecutor get queryExecutor => container!.make<orm.QueryExecutor>();
|
orm.QueryExecutor get queryExecutor => container!.make<orm.QueryExecutor>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension RedisExecutor on RequestContext {
|
||||||
|
Cache get cache => container!.make<RedisCache>().cache;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:angel3_auth/angel3_auth.dart';
|
||||||
|
import 'package:angel3_framework/angel3_framework.dart';
|
||||||
|
|
||||||
|
import 'package:dde_gesture_manager_api/models.dart';
|
||||||
|
|
||||||
|
RequestHandler jwtMiddleware() {
|
||||||
|
return (RequestContext req, ResponseContext res, {bool throwError = true}) async {
|
||||||
|
bool _reject(ResponseContext res) {
|
||||||
|
if (throwError) {
|
||||||
|
res.statusCode = 403;
|
||||||
|
throw AngelHttpException.forbidden();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.container != null) {
|
||||||
|
var reqContainer = req.container!;
|
||||||
|
if (reqContainer.has<User>() || req.method == 'OPTIONS') {
|
||||||
|
return true;
|
||||||
|
} else if (reqContainer.has<Future<User>>()) {
|
||||||
|
User user = await reqContainer.makeAsync<User>();
|
||||||
|
var authToken = req.container!.make<AuthToken>();
|
||||||
|
if (user.secret(req.app!.configuration['password_salt']) != authToken.payload[UserFields.password]) {
|
||||||
|
return _reject(res);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return _reject(res);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return _reject(res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:angel3_framework/angel3_framework.dart';
|
|
||||||
import 'package:dde_gesture_manager_api/models.dart';
|
|
||||||
import 'controller_extensions.dart';
|
|
||||||
|
|
||||||
Future configureServer(Angel app) async {
|
|
||||||
app.get(
|
|
||||||
'/user/int:id',
|
|
||||||
(req, res) async {
|
|
||||||
var user = await (UserQuery()..where?.metadata.contains({"uid": req.params[UserFields.id]}))
|
|
||||||
.getOne(req.queryExecutor);
|
|
||||||
|
|
||||||
return res.json(user.value);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:angel3_framework/angel3_framework.dart';
|
import 'package:angel3_framework/angel3_framework.dart';
|
||||||
import 'package:file/file.dart';
|
import 'package:file/file.dart';
|
||||||
import 'controllers/user_controllers.dart' as user_controllers;
|
import 'controllers/auth_controllers.dart' as auth_controllers;
|
||||||
import 'controllers/system_controllers.dart' as system_controllers;
|
import 'controllers/system_controllers.dart' as system_controllers;
|
||||||
|
|
||||||
/// Put your app routes here!
|
/// Put your app routes here!
|
||||||
@@ -11,9 +11,17 @@ import 'controllers/system_controllers.dart' as system_controllers;
|
|||||||
|
|
||||||
AngelConfigurer configureServer(FileSystem fileSystem) {
|
AngelConfigurer configureServer(FileSystem fileSystem) {
|
||||||
return (Angel app) async {
|
return (Angel app) async {
|
||||||
|
// ParseBody middleware
|
||||||
|
app.fallback((req, res) async {
|
||||||
|
if (req.method == "POST") {
|
||||||
|
await req.parseBody();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Typically, you want to mount controllers first, after any global middleware.
|
// Typically, you want to mount controllers first, after any global middleware.
|
||||||
await app.configure(system_controllers.configureServerWithFileSystem(fileSystem));
|
await app.configure(system_controllers.configureServerWithFileSystem(fileSystem));
|
||||||
await app.configure(user_controllers.configureServer);
|
await app.configure(auth_controllers.configureServer);
|
||||||
|
|
||||||
// Throw a 404 if no route matched the request.
|
// Throw a 404 if no route matched the request.
|
||||||
app.fallback((req, res) => throw AngelHttpException.notFound());
|
app.fallback((req, res) => throw AngelHttpException.notFound());
|
||||||
|
|||||||
+5
-1
@@ -3,7 +3,7 @@ version: 1.0.0
|
|||||||
description: An ORM starter application for Angel3 framework
|
description: An ORM starter application for Angel3 framework
|
||||||
publish_to: none
|
publish_to: none
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.12.0 <3.0.0'
|
sdk: '>=2.15.0 <3.0.0'
|
||||||
dependencies:
|
dependencies:
|
||||||
angel3_auth: ^4.0.0
|
angel3_auth: ^4.0.0
|
||||||
angel3_configuration: ^4.1.0
|
angel3_configuration: ^4.1.0
|
||||||
@@ -19,6 +19,10 @@ dependencies:
|
|||||||
optional: ^6.0.0
|
optional: ^6.0.0
|
||||||
logging: ^1.0.0
|
logging: ^1.0.0
|
||||||
yaml: ^3.1.0
|
yaml: ^3.1.0
|
||||||
|
mailer: ^5.0.2
|
||||||
|
uuid: ^3.0.5
|
||||||
|
neat_cache:
|
||||||
|
path: 3rd_party/neat_cache
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
angel3_hot: ^4.2.0
|
angel3_hot: ^4.2.0
|
||||||
angel3_jinja: ^2.0.1
|
angel3_jinja: ^2.0.1
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
dart pub get
|
||||||
|
dart run build_runner build
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block title %}确认注册{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>确认注册</h1>
|
||||||
|
<p>如果是您本人点击了《DDE手势管理器》的登录/注册按钮,并收到了本邮件,请点击下面的链接以完成注册,完成后请回到软件中使用之前填入的邮箱和密码登录账户。</p>
|
||||||
|
<p>如果您对上面的操作并不知情,则可能是其他用户错误使用了您的邮箱地址进行了注册,请无视本邮件,不要点击下面的链接,谢谢合作~</p>
|
||||||
|
<a href="{{ confirm_url }}">{{ confirm_url }}</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block title %}注册结果{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>注册结果</h1>
|
||||||
|
{% if success %}
|
||||||
|
<p>注册成功~</p>
|
||||||
|
{% else %}
|
||||||
|
<p>注册失败..</p>
|
||||||
|
<p>可能是因为:</p>
|
||||||
|
<ul>
|
||||||
|
<li>本链接已经超过三十分钟的有效期,请重新在软件中点击注册按钮</li>
|
||||||
|
<li>本链接已经被点击并注册成功,请勿重复点击</li>
|
||||||
|
<li>其他错误……非常抱歉,如果方便的话请通过邮件联系我~</li>
|
||||||
|
</ul>
|
||||||
|
{% endif%}
|
||||||
|
{% endblock %}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
* first version
|
||||||
Vendored
+201
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# markdown_core
|
||||||
|
|
||||||
|
Parse markdown and render it into rich text.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
``` dart
|
||||||
|
Markdown(
|
||||||
|
data: markdownDataString,
|
||||||
|
linkTap: (link) => print('点击了链接 $link'),
|
||||||
|
textStyle: // your text style ,
|
||||||
|
image: (imageUrl) {
|
||||||
|
print('imageUrl $imageUrl');
|
||||||
|
return // Your image widget ;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
+340
@@ -0,0 +1,340 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:html_unescape/html_unescape_small.dart';
|
||||||
|
import 'package:markdown/markdown.dart' as md;
|
||||||
|
import 'package:markdown_core/text_style.dart';
|
||||||
|
|
||||||
|
/// 递归解析标签
|
||||||
|
/// [_elementList] 每个标签依次放入该集合
|
||||||
|
/// 在[visitElementBefore]时添加
|
||||||
|
/// 在[visitElementAfter]时将其移除
|
||||||
|
|
||||||
|
class MarkdownBuilder implements md.NodeVisitor {
|
||||||
|
MarkdownBuilder(
|
||||||
|
this.context,
|
||||||
|
this.linkTap,
|
||||||
|
this.widgetImage,
|
||||||
|
this.maxWidth,
|
||||||
|
this.defaultTextStyle, {
|
||||||
|
this.tagTextStyle = defaultTagTextStyle,
|
||||||
|
required this.onCodeCopied,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _widgets = <Widget>[];
|
||||||
|
// int _level = 0;
|
||||||
|
List<_Element> _elementList = <_Element>[];
|
||||||
|
|
||||||
|
final TextStyle defaultTextStyle;
|
||||||
|
final TagTextStyle tagTextStyle;
|
||||||
|
|
||||||
|
final BuildContext context;
|
||||||
|
final LinkTap linkTap;
|
||||||
|
final WidgetImage widgetImage;
|
||||||
|
final double maxWidth;
|
||||||
|
final Function onCodeCopied;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool visitElementBefore(md.Element element) {
|
||||||
|
// _level++;
|
||||||
|
// debugPrint('visitElementBefore $_level ${element.textContent}');
|
||||||
|
|
||||||
|
String lastTag = '';
|
||||||
|
if (_elementList.isNotEmpty) {
|
||||||
|
lastTag = _elementList.last.tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
var textStyle = tagTextStyle(
|
||||||
|
lastTag,
|
||||||
|
element.tag,
|
||||||
|
_elementList.isNotEmpty ? _elementList.last.textStyle : defaultTextStyle,
|
||||||
|
);
|
||||||
|
|
||||||
|
_elementList.add(_Element(
|
||||||
|
element.tag,
|
||||||
|
textStyle,
|
||||||
|
element.attributes,
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void visitText(md.Text text) {
|
||||||
|
// debugPrint('text ${text.text}');
|
||||||
|
|
||||||
|
if (_elementList.isEmpty) return;
|
||||||
|
var last = _elementList.last;
|
||||||
|
last.textSpans ??= [];
|
||||||
|
|
||||||
|
// 替换特定字符串
|
||||||
|
var content = text.text.replaceAll('>', '>');
|
||||||
|
content = content.replaceAll('<', '<');
|
||||||
|
|
||||||
|
if (last.tag == 'a') {
|
||||||
|
last.textSpans?.add(TextSpan(
|
||||||
|
text: content,
|
||||||
|
style: last.textStyle,
|
||||||
|
recognizer: TapGestureRecognizer()
|
||||||
|
..onTap = () {
|
||||||
|
debugPrint(last.attributes.toString());
|
||||||
|
linkTap(last.attributes['href'] ?? '');
|
||||||
|
},
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
last.textSpans?.add(TextSpan(
|
||||||
|
text: content,
|
||||||
|
style: last.textStyle,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final padding = const EdgeInsets.fromLTRB(0, 5, 0, 5);
|
||||||
|
|
||||||
|
String getTextFromElement(dynamic element) {
|
||||||
|
String result = '';
|
||||||
|
if (element is List) {
|
||||||
|
result = element.map(getTextFromElement).join('\n');
|
||||||
|
} else if (element is md.Element) {
|
||||||
|
result = result = element.children?.map(getTextFromElement).join('\n') ?? '';
|
||||||
|
} else {
|
||||||
|
result = element.text;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void visitElementAfter(md.Element element) {
|
||||||
|
// debugPrint('visitElementAfter $_level ${element.tag}');
|
||||||
|
// _level--;
|
||||||
|
|
||||||
|
if (_elementList.isEmpty) return;
|
||||||
|
var last = _elementList.last;
|
||||||
|
_elementList.removeLast();
|
||||||
|
var tempWidget;
|
||||||
|
if (kTextTags.indexOf(element.tag) != -1) {
|
||||||
|
if (_elementList.isNotEmpty && kTextParentTags.indexOf(_elementList.last.tag) != -1) {
|
||||||
|
// 内联标签处理
|
||||||
|
_elementList.last.textSpans ??= [];
|
||||||
|
_elementList.last.textSpans?.addAll(last.textSpans ?? []);
|
||||||
|
} else {
|
||||||
|
if (last.textSpans?.isNotEmpty ?? false) {
|
||||||
|
tempWidget = SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: last.textSpans,
|
||||||
|
style: last.textStyle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ('li' == element.tag) {
|
||||||
|
tempWidget = _resolveToLi(last);
|
||||||
|
} else if ('pre' == element.tag) {
|
||||||
|
var preCode = HtmlUnescape().convert(getTextFromElement(element.children));
|
||||||
|
tempWidget = _resolveToPre(last, preCode);
|
||||||
|
} else if ('blockquote' == element.tag) {
|
||||||
|
tempWidget = _resolveToBlockquote(last);
|
||||||
|
} else if ('img' == element.tag) {
|
||||||
|
if (_elementList.isNotEmpty && (_elementList.last.textSpans?.isNotEmpty ?? false)) {
|
||||||
|
_widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
children: _elementList.last.textSpans,
|
||||||
|
style: _elementList.last.textStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_elementList.last.textSpans = null;
|
||||||
|
}
|
||||||
|
// debugPrint(element.attributes.toString());
|
||||||
|
//_elementList.clear();
|
||||||
|
_widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: widgetImage(element.attributes['src'] ?? ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (last.widgets?.isNotEmpty ?? false) {
|
||||||
|
if (last.widgets?.length == 1) {
|
||||||
|
tempWidget = last.widgets?[0];
|
||||||
|
} else {
|
||||||
|
tempWidget = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: last.widgets ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempWidget != null) {
|
||||||
|
if (_elementList.isEmpty) {
|
||||||
|
_widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: tempWidget,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_elementList.last.widgets ??= [];
|
||||||
|
if (tempWidget is List<Widget>) {
|
||||||
|
_elementList.last.widgets?.addAll(tempWidget);
|
||||||
|
} else {
|
||||||
|
_elementList.last.widgets?.add(tempWidget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> build(List<md.Node> nodes) {
|
||||||
|
_widgets.clear();
|
||||||
|
|
||||||
|
for (md.Node node in nodes) {
|
||||||
|
// _level = 0;
|
||||||
|
_elementList.clear();
|
||||||
|
|
||||||
|
node.accept(this);
|
||||||
|
}
|
||||||
|
return _widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _resolveToLi(_Element last) {
|
||||||
|
int liNum = 1;
|
||||||
|
_elementList.forEach((element) {
|
||||||
|
if (element.tag == 'li') liNum++;
|
||||||
|
});
|
||||||
|
List<Widget> widgets = last.widgets ?? [];
|
||||||
|
List<InlineSpan> spans = [];
|
||||||
|
spans.addAll(last.textSpans ?? []);
|
||||||
|
widgets.insert(
|
||||||
|
0,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
8,
|
||||||
|
((last.textStyle.fontSize ?? 0) * 2 - 10) / 2.2,
|
||||||
|
8,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.circle,
|
||||||
|
size: 10,
|
||||||
|
color: last.textStyle.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: maxWidth - (26 * liNum),
|
||||||
|
child: RichText(
|
||||||
|
strutStyle: StrutStyle(
|
||||||
|
height: 1,
|
||||||
|
fontSize: last.textStyle.fontSize,
|
||||||
|
forceStrutHeight: true,
|
||||||
|
leading: 1,
|
||||||
|
),
|
||||||
|
// textAlign: TextAlign.center,
|
||||||
|
text: TextSpan(
|
||||||
|
children: spans,
|
||||||
|
style: last.textStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
/// 如果是顶层,返回column
|
||||||
|
if (liNum == 1) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: widgets,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _resolveToPre(_Element last, String preCode) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 5, 15, 5),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).brightness == Brightness.dark ? const Color(0xff111111) : const Color(0xffeeeeee),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 14, 8, 0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: last.widgets ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(Icons.copy_outlined),
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: preCode)).then((_) {
|
||||||
|
onCodeCopied();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _resolveToBlockquote(_Element last) {
|
||||||
|
return IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: double.infinity,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: last.widgets ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Element {
|
||||||
|
_Element(
|
||||||
|
this.tag,
|
||||||
|
this.textStyle,
|
||||||
|
this.attributes,
|
||||||
|
);
|
||||||
|
|
||||||
|
final String tag;
|
||||||
|
List<Widget>? widgets;
|
||||||
|
List<TextSpan>? textSpans;
|
||||||
|
TextStyle textStyle;
|
||||||
|
Map<String, String> attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 链接点击
|
||||||
|
typedef void LinkTap(String link);
|
||||||
|
|
||||||
|
typedef Widget WidgetImage(String imageUrl);
|
||||||
|
|
||||||
|
typedef TextStyle TagTextStyle(String lastTag, String tag, TextStyle textStyle);
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
library markdown_core;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:markdown/markdown.dart';
|
||||||
|
import 'package:markdown_core/builder.dart';
|
||||||
|
import 'package:markdown_core/text_style.dart';
|
||||||
|
|
||||||
|
class Markdown extends StatefulWidget {
|
||||||
|
const Markdown({
|
||||||
|
Key? key,
|
||||||
|
required this.data,
|
||||||
|
required this.linkTap,
|
||||||
|
required this.image,
|
||||||
|
required this.onCodeCopied,
|
||||||
|
this.maxWidth,
|
||||||
|
this.textStyle,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final String data;
|
||||||
|
|
||||||
|
final LinkTap linkTap;
|
||||||
|
|
||||||
|
final WidgetImage image;
|
||||||
|
|
||||||
|
final double? maxWidth;
|
||||||
|
|
||||||
|
final TextStyle? textStyle;
|
||||||
|
|
||||||
|
final Function onCodeCopied;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MarkdownState createState() => MarkdownState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkdownState extends State<Markdown> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: _parseMarkdown(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _parseMarkdown() {
|
||||||
|
// debugPrint(markdownToHtml(
|
||||||
|
// widget.data,
|
||||||
|
// extensionSet: ExtensionSet.gi法inaltHubWeb,
|
||||||
|
// ));
|
||||||
|
final List<String> lines = widget.data.split(RegExp(r'\r?\n'));
|
||||||
|
final nodes = Document(
|
||||||
|
extensionSet: ExtensionSet.gitHubWeb,
|
||||||
|
).parseLines(lines);
|
||||||
|
return MarkdownBuilder(
|
||||||
|
context,
|
||||||
|
widget.linkTap,
|
||||||
|
widget.image,
|
||||||
|
widget.maxWidth ?? MediaQuery.of(context).size.width,
|
||||||
|
widget.textStyle ?? defaultTextStyle(context),
|
||||||
|
onCodeCopied: widget.onCodeCopied,
|
||||||
|
).build(nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const List<String> kTextTags = const <String>[
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'p',
|
||||||
|
'code',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'del',
|
||||||
|
'a',
|
||||||
|
];
|
||||||
|
|
||||||
|
const List<String> kTextParentTags = const <String>[
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'p',
|
||||||
|
'li',
|
||||||
|
];
|
||||||
|
|
||||||
|
TextStyle defaultTextStyle(BuildContext context) => TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1.8,
|
||||||
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? const Color(0xffaaaaaa)
|
||||||
|
: const Color(0xff444444),
|
||||||
|
);
|
||||||
|
|
||||||
|
TextStyle defaultTagTextStyle(String lastTag, String tag, TextStyle textStyle) {
|
||||||
|
switch (tag) {
|
||||||
|
case 'h1':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontSize: (textStyle.fontSize ?? 0) + 9,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'h2':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontSize: (textStyle.fontSize ?? 0) + 6,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'h3':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontSize: (textStyle.fontSize ?? 0) + 4,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'h4':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontSize: (textStyle.fontSize ?? 0) + 3,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'h5':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontSize: (textStyle.fontSize ?? 0) + 2,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'h6':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontSize: (textStyle.fontSize ?? 0) + 1,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
|
break;
|
||||||
|
case 'li':
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontSize: (textStyle.fontSize ?? 0) - 3,
|
||||||
|
color: textStyle.color?.withAlpha(200),
|
||||||
|
);
|
||||||
|
if (lastTag == 'p') {
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
color: Colors.red.shade800,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'strong':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'em':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'del':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
decoration: TextDecoration.lineThrough,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'a':
|
||||||
|
textStyle = textStyle.copyWith(
|
||||||
|
color: Colors.blue,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return textStyle;
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
name: markdown_core
|
||||||
|
description: Parse markdown and render it into rich text.
|
||||||
|
version: 1.0.0
|
||||||
|
homepage: https://github.com/xia-weiyang/markdown_core
|
||||||
|
repository: https://github.com/xia-weiyang/markdown_core
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=2.12.0 <3.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
markdown: ^4.0.1
|
||||||
|
html_unescape: ^2.0.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter.
|
||||||
|
flutter:
|
||||||
|
|
||||||
|
# To add assets to your package, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
#
|
||||||
|
# For details regarding assets in packages, see
|
||||||
|
# https://flutter.dev/assets-and-images/#from-packages
|
||||||
|
#
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/assets-and-images/#resolution-aware.
|
||||||
|
|
||||||
|
# To add custom fonts to your package, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts in packages, see
|
||||||
|
# https://flutter.dev/custom-fonts/#from-packages
|
||||||
@@ -9,6 +9,7 @@ class MdPreview extends StatefulWidget {
|
|||||||
this.padding = const EdgeInsets.all(0.0),
|
this.padding = const EdgeInsets.all(0.0),
|
||||||
this.onTapLink,
|
this.onTapLink,
|
||||||
required this.widgetImage,
|
required this.widgetImage,
|
||||||
|
required this.onCodeCopied,
|
||||||
this.textStyle,
|
this.textStyle,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ class MdPreview extends StatefulWidget {
|
|||||||
final WidgetImage widgetImage;
|
final WidgetImage widgetImage;
|
||||||
final TextStyle? textStyle;
|
final TextStyle? textStyle;
|
||||||
|
|
||||||
|
final Function onCodeCopied;
|
||||||
|
|
||||||
/// Call this method when it tap link of markdown.
|
/// Call this method when it tap link of markdown.
|
||||||
/// If [onTapLink] is null,it will open the link with your default browser.
|
/// If [onTapLink] is null,it will open the link with your default browser.
|
||||||
final TapLinkCallback? onTapLink;
|
final TapLinkCallback? onTapLink;
|
||||||
@@ -46,6 +49,7 @@ class MdPreviewState extends State<MdPreview>
|
|||||||
},
|
},
|
||||||
image: widget.widgetImage,
|
image: widget.widgetImage,
|
||||||
textStyle: widget.textStyle,
|
textStyle: widget.textStyle,
|
||||||
|
onCodeCopied: widget.onCodeCopied,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,9 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
shared_preferences: ^2.0.4
|
shared_preferences: ^2.0.4
|
||||||
markdown_core: ^1.0.0
|
markdown_core:
|
||||||
|
path:
|
||||||
|
../markdown_core
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
|
||||||
/// [UOS设计指南](https://docs.uniontech.com/zh/content/t_dbG3kBK9iDf9B963ok)
|
/// [UOS设计指南](https://docs.uniontech.com/zh/content/t_dbG3kBK9iDf9B963ok)
|
||||||
|
|
||||||
const double localManagerPanelWidth = 260;
|
const double localManagerPanelWidth = 260;
|
||||||
|
|
||||||
const double marketPanelWidth = 300;
|
const double marketOrMePanelWidth = 300;
|
||||||
|
|
||||||
const shortDuration = const Duration(milliseconds: 100);
|
const shortDuration = const Duration(milliseconds: 100);
|
||||||
|
|
||||||
@@ -39,5 +38,5 @@ const List<String> builtInCommands = [
|
|||||||
|
|
||||||
enum PanelType {
|
enum PanelType {
|
||||||
local_manager,
|
local_manager,
|
||||||
market,
|
market_or_me,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,7 @@ class SPKeys {
|
|||||||
static final String brightnessMode = 'BRIGHTNESS_MODE';
|
static final String brightnessMode = 'BRIGHTNESS_MODE';
|
||||||
static final String appliedSchemeId = 'APPLIED_SCHEME_ID';
|
static final String appliedSchemeId = 'APPLIED_SCHEME_ID';
|
||||||
static final String userLanguage = 'USER_LANGUAGE';
|
static final String userLanguage = 'USER_LANGUAGE';
|
||||||
|
static final String accessToken = 'USER_ACCESS_TOKEN';
|
||||||
|
static final String loginEmail = 'USER_LOGIN_EMAIL';
|
||||||
|
static final String ignoredUpdateVersion = 'IGNORED_UPDATE_VERSION';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
extension StringNotNull on String? {
|
extension StringNotNull on String? {
|
||||||
bool get notNull => this != null && this != '';
|
bool get notNull => this != null && this != '';
|
||||||
|
|
||||||
|
bool get isNull => !notNull;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dde_gesture_manager/constants/sp_keys.dart';
|
||||||
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
|
import 'package:dde_gesture_manager/utils/helper.dart';
|
||||||
|
import 'package:dde_gesture_manager/utils/notificator.dart';
|
||||||
|
import 'package:dde_gesture_manager_api/apis.dart';
|
||||||
|
import 'package:dde_gesture_manager_api/models.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
typedef T BeanBuilder<T>(Map res);
|
||||||
|
|
||||||
|
typedef T HandleRespBuild<T>(http.Response resp);
|
||||||
|
|
||||||
|
getStatusCodeFunc<int>(Map resp) => resp["statusCode"];
|
||||||
|
|
||||||
|
class HttpErrorCode extends Error {
|
||||||
|
int statusCode;
|
||||||
|
|
||||||
|
HttpErrorCode(this.statusCode, {this.message});
|
||||||
|
|
||||||
|
String? message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '[$statusCode] $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
class Api {
|
||||||
|
static _handleHttpError(e) {
|
||||||
|
if (e is SocketException) {
|
||||||
|
Notificator.error(
|
||||||
|
H().topContext,
|
||||||
|
title: LocaleKeys.info_server_error_title.tr(),
|
||||||
|
description: LocaleKeys.info_server_error_description.tr(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static HandleRespBuild<T> _handleRespBuild<T>(BeanBuilder<T> builder) => (http.Response resp) {
|
||||||
|
if (builder == getStatusCodeFunc) return builder({"statusCode": resp.statusCode});
|
||||||
|
T res;
|
||||||
|
try {
|
||||||
|
res = builder(json.decode(resp.body));
|
||||||
|
} catch (e) {
|
||||||
|
throw HttpErrorCode(resp.statusCode, message: resp.body);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
static Future<T> _get<T>(
|
||||||
|
String path,
|
||||||
|
BeanBuilder<T> builder, {
|
||||||
|
Map<String, dynamic>? queryParams,
|
||||||
|
bool ignoreToken = false,
|
||||||
|
bool ignoreErrorHandle = false,
|
||||||
|
}) =>
|
||||||
|
http
|
||||||
|
.get(
|
||||||
|
Uri(
|
||||||
|
scheme: Apis.apiScheme,
|
||||||
|
host: Apis.apiHost,
|
||||||
|
port: Apis.apiPort,
|
||||||
|
queryParameters: queryParams,
|
||||||
|
path: path,
|
||||||
|
),
|
||||||
|
headers: <String, String>{
|
||||||
|
HttpHeaders.contentTypeHeader: ContentType.json.value,
|
||||||
|
}..addAll(
|
||||||
|
ignoreToken ? {} : {HttpHeaders.authorizationHeader: 'Bearer ${H().sp.getString(SPKeys.accessToken)}'}),
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
_handleRespBuild<T>(builder),
|
||||||
|
onError: (e) {
|
||||||
|
if (ignoreErrorHandle)
|
||||||
|
throw e;
|
||||||
|
else
|
||||||
|
return _handleHttpError(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<T> _post<T>(
|
||||||
|
String path,
|
||||||
|
BeanBuilder<T> builder, {
|
||||||
|
Map<String, dynamic>? body,
|
||||||
|
bool ignoreToken = false,
|
||||||
|
bool ignoreErrorHandle = false,
|
||||||
|
}) =>
|
||||||
|
http
|
||||||
|
.post(
|
||||||
|
Uri(
|
||||||
|
scheme: Apis.apiScheme,
|
||||||
|
host: Apis.apiHost,
|
||||||
|
port: Apis.apiPort,
|
||||||
|
path: path,
|
||||||
|
),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
headers: <String, String>{
|
||||||
|
HttpHeaders.contentTypeHeader: ContentType.json.value,
|
||||||
|
}..addAll(
|
||||||
|
ignoreToken ? {} : {HttpHeaders.authorizationHeader: 'Bearer ${H().sp.getString(SPKeys.accessToken)}'}),
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
_handleRespBuild<T>(builder),
|
||||||
|
onError: (e) {
|
||||||
|
if (ignoreErrorHandle)
|
||||||
|
throw e;
|
||||||
|
else
|
||||||
|
return _handleHttpError(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<LoginSuccess?> loginOrSignup({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) =>
|
||||||
|
_post<LoginSuccess?>(
|
||||||
|
Apis.auth.loginOrSignup,
|
||||||
|
LoginSuccessSerializer.fromMap,
|
||||||
|
body: {
|
||||||
|
UserFields.email: email,
|
||||||
|
UserFields.password: password,
|
||||||
|
},
|
||||||
|
ignoreToken: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<AppVersion?> checkAppVersion({ignoreErrorHandle = false}) => _get<AppVersion?>(
|
||||||
|
Apis.system.appVersion,
|
||||||
|
AppVersionSerializer.fromMap,
|
||||||
|
ignoreToken: true,
|
||||||
|
ignoreErrorHandle: ignoreErrorHandle,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dde_gesture_manager/builder/provider_annotation.dart';
|
import 'package:dde_gesture_manager/builder/provider_annotation.dart';
|
||||||
import 'package:dde_gesture_manager/constants/sp_keys.dart';
|
import 'package:dde_gesture_manager/constants/sp_keys.dart';
|
||||||
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
import 'package:dde_gesture_manager/utils/helper.dart';
|
import 'package:dde_gesture_manager/utils/helper.dart';
|
||||||
|
|
||||||
enum BrightnessMode {
|
enum BrightnessMode {
|
||||||
@@ -14,11 +15,49 @@ class Configs {
|
|||||||
BrightnessMode? brightnessMode;
|
BrightnessMode? brightnessMode;
|
||||||
|
|
||||||
@ProviderModelProp()
|
@ProviderModelProp()
|
||||||
String? appliedSchemeId;
|
String? get appliedSchemeId => _appliedSchemeId;
|
||||||
|
|
||||||
|
set appliedSchemeId(String? schemeId) {
|
||||||
|
_appliedSchemeId = schemeId;
|
||||||
|
if (schemeId.notNull)
|
||||||
|
H().sp.updateString(SPKeys.appliedSchemeId, schemeId!);
|
||||||
|
else
|
||||||
|
H().sp.remove(SPKeys.appliedSchemeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _appliedSchemeId;
|
||||||
|
|
||||||
|
@ProviderModelProp()
|
||||||
|
String? get accessToken => _accessToken;
|
||||||
|
|
||||||
|
set accessToken(String? token) {
|
||||||
|
_accessToken = token;
|
||||||
|
if (token.notNull)
|
||||||
|
H().sp.updateString(SPKeys.accessToken, token!);
|
||||||
|
else
|
||||||
|
H().sp.remove(SPKeys.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _accessToken;
|
||||||
|
|
||||||
|
@ProviderModelProp()
|
||||||
|
String? get email => _email;
|
||||||
|
|
||||||
|
set email(String? emailAddress) {
|
||||||
|
_email = emailAddress;
|
||||||
|
if (emailAddress.notNull)
|
||||||
|
H().sp.updateString(SPKeys.loginEmail, emailAddress!);
|
||||||
|
else
|
||||||
|
H().sp.remove(SPKeys.loginEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _email;
|
||||||
|
|
||||||
Configs() {
|
Configs() {
|
||||||
this.brightnessMode =
|
this.brightnessMode =
|
||||||
BrightnessMode.values[H().sp.getInt(SPKeys.brightnessMode)?.clamp(0, BrightnessMode.values.length - 1) ?? 0];
|
BrightnessMode.values[H().sp.getInt(SPKeys.brightnessMode)?.clamp(0, BrightnessMode.values.length - 1) ?? 0];
|
||||||
this.appliedSchemeId = H().sp.getString(SPKeys.appliedSchemeId);
|
this.appliedSchemeId = H().sp.getString(SPKeys.appliedSchemeId);
|
||||||
|
this.accessToken = H().sp.getString(SPKeys.accessToken);
|
||||||
|
this.email = H().sp.getString(SPKeys.loginEmail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,10 @@ class ContentLayout {
|
|||||||
bool? localManagerOpened;
|
bool? localManagerOpened;
|
||||||
|
|
||||||
@ProviderModelProp()
|
@ProviderModelProp()
|
||||||
bool? marketOpened;
|
bool? marketOrMeOpened;
|
||||||
|
|
||||||
|
@ProviderModelProp()
|
||||||
|
bool? currentIsMarket = true;
|
||||||
|
|
||||||
|
bool get isMarket => currentIsMarket ?? true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:dde_gesture_manager/models/content_layout.provider.dart';
|
|||||||
import 'package:dde_gesture_manager/models/scheme.provider.dart';
|
import 'package:dde_gesture_manager/models/scheme.provider.dart';
|
||||||
import 'package:dde_gesture_manager/pages/gesture_editor.dart';
|
import 'package:dde_gesture_manager/pages/gesture_editor.dart';
|
||||||
import 'package:dde_gesture_manager/pages/local_manager.dart';
|
import 'package:dde_gesture_manager/pages/local_manager.dart';
|
||||||
import 'package:dde_gesture_manager/pages/market.dart';
|
import 'package:dde_gesture_manager/pages/market_or_me.dart';
|
||||||
import 'package:dde_gesture_manager/utils/helper.dart';
|
import 'package:dde_gesture_manager/utils/helper.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class _ContentState extends State<Content> {
|
|||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => ContentLayoutProvider()
|
create: (context) => ContentLayoutProvider()
|
||||||
..localManagerOpened = preferredPanelsStatus.localManagerPanelOpened
|
..localManagerOpened = preferredPanelsStatus.localManagerPanelOpened
|
||||||
..marketOpened = preferredPanelsStatus.marketPanelOpened,
|
..marketOrMeOpened = preferredPanelsStatus.marketOrMePanelOpened,
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => CopiedGesturePropProvider.empty(),
|
create: (context) => CopiedGesturePropProvider.empty(),
|
||||||
@@ -42,7 +42,7 @@ class _ContentState extends State<Content> {
|
|||||||
Future.microtask(
|
Future.microtask(
|
||||||
() => context.read<ContentLayoutProvider>().setProps(
|
() => context.read<ContentLayoutProvider>().setProps(
|
||||||
localManagerOpened: preferredPanelsStatus.localManagerPanelOpened,
|
localManagerOpened: preferredPanelsStatus.localManagerPanelOpened,
|
||||||
marketOpened: preferredPanelsStatus.marketPanelOpened,
|
marketOrMeOpened: preferredPanelsStatus.marketOrMePanelOpened,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ class _ContentState extends State<Content> {
|
|||||||
children: [
|
children: [
|
||||||
LocalManager(),
|
LocalManager(),
|
||||||
GestureEditor(),
|
GestureEditor(),
|
||||||
Market(),
|
MarketOrMe(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ class GestureEditor extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
Visibility(
|
Visibility(
|
||||||
visible: layoutProvider.marketOpened == false,
|
visible: layoutProvider.marketOrMeOpened == false,
|
||||||
child: DButton(
|
child: DButton(
|
||||||
width: defaultButtonHeight,
|
width: defaultButtonHeight,
|
||||||
onTap: () => H.openPanel(context, PanelType.market),
|
onTap: () => H.openPanel(context, PanelType.market_or_me),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
CupertinoIcons.cart,
|
layoutProvider.isMarket ? CupertinoIcons.cart : CupertinoIcons.person,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:dde_gesture_manager/constants/constants.dart';
|
import 'package:dde_gesture_manager/constants/constants.dart';
|
||||||
import 'package:dde_gesture_manager/constants/sp_keys.dart';
|
|
||||||
import 'package:dde_gesture_manager/extensions.dart';
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
import 'package:dde_gesture_manager/models/configs.provider.dart';
|
import 'package:dde_gesture_manager/models/configs.provider.dart';
|
||||||
import 'package:dde_gesture_manager/models/content_layout.provider.dart';
|
import 'package:dde_gesture_manager/models/content_layout.provider.dart';
|
||||||
@@ -9,7 +8,6 @@ import 'package:dde_gesture_manager/models/local_schemes_provider.dart';
|
|||||||
import 'package:dde_gesture_manager/models/scheme.dart';
|
import 'package:dde_gesture_manager/models/scheme.dart';
|
||||||
import 'package:dde_gesture_manager/models/scheme.provider.dart';
|
import 'package:dde_gesture_manager/models/scheme.provider.dart';
|
||||||
import 'package:dde_gesture_manager/models/settings.provider.dart';
|
import 'package:dde_gesture_manager/models/settings.provider.dart';
|
||||||
import 'package:dde_gesture_manager/utils/helper.dart';
|
|
||||||
import 'package:dde_gesture_manager/widgets/dde_button.dart';
|
import 'package:dde_gesture_manager/widgets/dde_button.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -247,7 +245,6 @@ class _LocalManagerState extends State<LocalManager> {
|
|||||||
var appliedId =
|
var appliedId =
|
||||||
localSchemes.firstWhere((ele) => ele.path == _selectedItemPath).scheme.id!;
|
localSchemes.firstWhere((ele) => ele.path == _selectedItemPath).scheme.id!;
|
||||||
appliedId.sout();
|
appliedId.sout();
|
||||||
H().sp.updateString(SPKeys.appliedSchemeId, appliedId);
|
|
||||||
context.read<ConfigsProvider>().setProps(appliedSchemeId: appliedId);
|
context.read<ConfigsProvider>().setProps(appliedSchemeId: appliedId);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import 'package:dde_gesture_manager/constants/constants.dart';
|
|
||||||
import 'package:dde_gesture_manager/extensions.dart';
|
|
||||||
import 'package:dde_gesture_manager/models/content_layout.provider.dart';
|
|
||||||
import 'package:dde_gesture_manager/widgets/dde_button.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class Market extends StatelessWidget {
|
|
||||||
const Market({
|
|
||||||
Key? key,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
var isOpen = context.watch<ContentLayoutProvider>().marketOpened == true;
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: mediumDuration,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
width: isOpen ? marketPanelWidth : 0,
|
|
||||||
child: OverflowBox(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
maxWidth: marketPanelWidth,
|
|
||||||
minWidth: marketPanelWidth,
|
|
||||||
child: Material(
|
|
||||||
color: context.t.backgroundColor,
|
|
||||||
elevation: isOpen ? 10 : 0,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
DButton(
|
|
||||||
width: defaultButtonHeight - 2,
|
|
||||||
height: defaultButtonHeight - 2,
|
|
||||||
onTap: () => context.read<ContentLayoutProvider>().setProps(marketOpened: !isOpen),
|
|
||||||
child: Icon(
|
|
||||||
CupertinoIcons.chevron_right_2,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
LocaleKeys.market_title,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
).tr(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(width: defaultButtonHeight),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
|
import 'package:dde_gesture_manager/constants/constants.dart';
|
||||||
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
|
import 'package:dde_gesture_manager/models/configs.provider.dart';
|
||||||
|
import 'package:dde_gesture_manager/models/content_layout.provider.dart';
|
||||||
|
import 'package:dde_gesture_manager/widgets/dde_button.dart';
|
||||||
|
import 'package:dde_gesture_manager/widgets/login.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class MarketOrMe extends StatelessWidget {
|
||||||
|
const MarketOrMe({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var layoutProvider = context.watch<ContentLayoutProvider>();
|
||||||
|
bool isOpen = layoutProvider.marketOrMeOpened == true;
|
||||||
|
bool isMarket = layoutProvider.isMarket;
|
||||||
|
bool showLogin = context.watch<ConfigsProvider>().accessToken.isNull && !isMarket;
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: mediumDuration,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
width: isOpen ? marketOrMePanelWidth * (showLogin ? 1.5 : 1) : 0,
|
||||||
|
child: OverflowBox(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
maxWidth: marketOrMePanelWidth * (showLogin ? 1.5 : 1),
|
||||||
|
minWidth: marketOrMePanelWidth * (showLogin ? 1.5 : 1),
|
||||||
|
child: Material(
|
||||||
|
color: context.t.backgroundColor,
|
||||||
|
elevation: isOpen ? 10 : 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
DButton(
|
||||||
|
width: defaultButtonHeight - 2,
|
||||||
|
height: defaultButtonHeight - 2,
|
||||||
|
onTap: () => context.read<ContentLayoutProvider>().setProps(marketOrMeOpened: !isOpen),
|
||||||
|
child: Icon(
|
||||||
|
CupertinoIcons.chevron_right_2,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
isMarket ? LocaleKeys.market_title : LocaleKeys.me_title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DButton(
|
||||||
|
width: defaultButtonHeight - 2,
|
||||||
|
height: defaultButtonHeight - 2,
|
||||||
|
onTap: () => context.read<ContentLayoutProvider>().setProps(currentIsMarket: !isMarket),
|
||||||
|
child: Icon(
|
||||||
|
!isMarket ? CupertinoIcons.cart : CupertinoIcons.person,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isMarket) buildMarketContent(context),
|
||||||
|
if (!isMarket) buildMeContent(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildMeContent(BuildContext context) {
|
||||||
|
var accessToken = context.watch<ConfigsProvider>().accessToken;
|
||||||
|
if (accessToken.isNull) return LoginWidget();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person, size: defaultButtonHeight),
|
||||||
|
Flexible(
|
||||||
|
child: AutoSizeText(
|
||||||
|
context.watch<ConfigsProvider>().email ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DButton.logout(
|
||||||
|
enabled: true,
|
||||||
|
onTap: () => context.read<ConfigsProvider>().setProps(accessToken: '', email: ''),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildMarketContent(BuildContext context) {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-11
@@ -29,34 +29,42 @@ class H {
|
|||||||
initSharedPreference() async {
|
initSharedPreference() async {
|
||||||
_sp = await SharedPreferences.getInstance();
|
_sp = await SharedPreferences.getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late BuildContext _topContext;
|
||||||
|
|
||||||
|
BuildContext get topContext => _topContext;
|
||||||
|
|
||||||
|
initTopContext(BuildContext context) {
|
||||||
|
_topContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
static void openPanel(BuildContext context, PanelType panelType) {
|
static void openPanel(BuildContext context, PanelType panelType) {
|
||||||
var windowWidth = MediaQuery.of(context).size.width;
|
var windowWidth = MediaQuery.of(context).size.width;
|
||||||
if (windowWidth < minWindowSize.width + localManagerPanelWidth + marketPanelWidth) {
|
if (windowWidth < minWindowSize.width + localManagerPanelWidth + marketOrMePanelWidth) {
|
||||||
context.read<ContentLayoutProvider>().setProps(
|
context.read<ContentLayoutProvider>().setProps(
|
||||||
localManagerOpened: panelType == PanelType.local_manager,
|
localManagerOpened: panelType == PanelType.local_manager,
|
||||||
marketOpened: panelType == PanelType.market,
|
marketOrMeOpened: panelType == PanelType.market_or_me,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
switch (panelType) {
|
switch (panelType) {
|
||||||
case PanelType.local_manager:
|
case PanelType.local_manager:
|
||||||
return context.read<ContentLayoutProvider>().setProps(localManagerOpened: true);
|
return context.read<ContentLayoutProvider>().setProps(localManagerOpened: true);
|
||||||
case PanelType.market:
|
case PanelType.market_or_me:
|
||||||
return context.read<ContentLayoutProvider>().setProps(marketOpened: true);
|
return context.read<ContentLayoutProvider>().setProps(marketOrMeOpened: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static PreferredPanelsStatus getPreferredPanelsStatus(double windowWidth) {
|
static PreferredPanelsStatus getPreferredPanelsStatus(double windowWidth) {
|
||||||
var preferredPanelsStatus = PreferredPanelsStatus(localManagerPanelOpened: true, marketPanelOpened: true);
|
var preferredPanelsStatus = PreferredPanelsStatus(localManagerPanelOpened: true, marketOrMePanelOpened: true);
|
||||||
if (windowWidth > minWindowSize.width + localManagerPanelWidth + marketPanelWidth)
|
if (windowWidth > minWindowSize.width + localManagerPanelWidth + marketOrMePanelWidth)
|
||||||
return preferredPanelsStatus;
|
return preferredPanelsStatus;
|
||||||
else if (windowWidth < minWindowSize.width + localManagerPanelWidth)
|
else if (windowWidth < minWindowSize.width + localManagerPanelWidth)
|
||||||
return preferredPanelsStatus
|
return preferredPanelsStatus
|
||||||
..marketPanelOpened = false
|
..marketOrMePanelOpened = false
|
||||||
..localManagerPanelOpened = false;
|
..localManagerPanelOpened = false;
|
||||||
else
|
else
|
||||||
return preferredPanelsStatus..marketPanelOpened = false;
|
return preferredPanelsStatus..marketOrMePanelOpened = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Gesture getGestureByName(String gestureName) => Gesture.values.findByName(gestureName) ?? Gesture.swipe;
|
static Gesture getGestureByName(String gestureName) => Gesture.values.findByName(gestureName) ?? Gesture.swipe;
|
||||||
@@ -108,15 +116,15 @@ class H {
|
|||||||
|
|
||||||
class PreferredPanelsStatus {
|
class PreferredPanelsStatus {
|
||||||
bool localManagerPanelOpened;
|
bool localManagerPanelOpened;
|
||||||
bool marketPanelOpened;
|
bool marketOrMePanelOpened;
|
||||||
|
|
||||||
PreferredPanelsStatus({
|
PreferredPanelsStatus({
|
||||||
required this.localManagerPanelOpened,
|
required this.localManagerPanelOpened,
|
||||||
required this.marketPanelOpened,
|
required this.marketOrMePanelOpened,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'PreferredPanelsStatus{localManagerPanelOpened: $localManagerPanelOpened, marketPanelOpened: $marketPanelOpened}';
|
return 'PreferredPanelsStatus{localManagerPanelOpened: $localManagerPanelOpened, marketOrMePanelOpened: $marketOrMePanelOpened}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ import 'package:dde_gesture_manager/constants/sp_keys.dart';
|
|||||||
import 'package:dde_gesture_manager/constants/supported_locales.dart';
|
import 'package:dde_gesture_manager/constants/supported_locales.dart';
|
||||||
import 'package:dde_gesture_manager/extensions.dart';
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
import 'package:dde_gesture_manager/generated/codegen_loader.g.dart';
|
import 'package:dde_gesture_manager/generated/codegen_loader.g.dart';
|
||||||
|
import 'package:dde_gesture_manager/http/api.dart';
|
||||||
import 'package:dde_gesture_manager/models/settings.provider.dart';
|
import 'package:dde_gesture_manager/models/settings.provider.dart';
|
||||||
import 'package:dde_gesture_manager/utils/helper.dart';
|
import 'package:dde_gesture_manager/utils/helper.dart';
|
||||||
|
import 'package:dde_gesture_manager/utils/notificator.dart';
|
||||||
|
import 'package:dde_gesture_manager_api/apis.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
|
||||||
import 'package:gsettings/gsettings.dart';
|
import 'package:gsettings/gsettings.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
bool _updateChecked = false;
|
||||||
|
|
||||||
Future<void> initEvents(BuildContext context) async {
|
Future<void> initEvents(BuildContext context) async {
|
||||||
|
H().initTopContext(context);
|
||||||
var isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
var isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
context.read<SettingsProvider>().setProps(isDarkMode: isDark);
|
context.read<SettingsProvider>().setProps(isDarkMode: isDark);
|
||||||
@@ -39,6 +48,32 @@ Future<void> initEvents(BuildContext context) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_updateChecked)
|
||||||
|
Api.checkAppVersion(ignoreErrorHandle: true).then((value) async {
|
||||||
|
_updateChecked = true;
|
||||||
|
var info = await PackageInfo.fromPlatform();
|
||||||
|
var _buildNumber = int.parse(info.buildNumber);
|
||||||
|
var _newVersionCode = value?.versionCode ?? 0;
|
||||||
|
var _ignoredVersionCode = H().sp.getInt(SPKeys.ignoredUpdateVersion) ?? 0;
|
||||||
|
if (_buildNumber < _newVersionCode && _ignoredVersionCode < _newVersionCode) {
|
||||||
|
Notificator.showConfirm(
|
||||||
|
title: LocaleKeys.info_new_version_title.tr(namedArgs: {'version': '${value?.versionName}'}),
|
||||||
|
description: LocaleKeys.info_new_version_description_for_startup.tr(namedArgs: {
|
||||||
|
'yes': LocaleKeys.str_yes.tr(),
|
||||||
|
'no': LocaleKeys.str_no.tr(),
|
||||||
|
}),
|
||||||
|
).then((confirmed) async {
|
||||||
|
if (confirmed == CustomButton.positiveButton) {
|
||||||
|
if (await canLaunch(Apis.appNewVersionUrl)) {
|
||||||
|
await launch(Apis.appNewVersionUrl);
|
||||||
|
}
|
||||||
|
} else if (confirmed == CustomButton.negativeButton) {
|
||||||
|
H().sp.updateInt(SPKeys.ignoredUpdateVersion, value?.versionCode ?? 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initConfigs() async {
|
Future<void> initConfigs() async {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
Future<void> initEvents(BuildContext context) async {
|
Future<void> initEvents(BuildContext context) async {
|
||||||
|
H().initTopContext(context);
|
||||||
var isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
var isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
context.read<SettingsProvider>().setProps(isDarkMode: isDark);
|
context.read<SettingsProvider>().setProps(isDarkMode: isDark);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:cherry_toast/cherry_toast.dart';
|
import 'package:cherry_toast/cherry_toast.dart';
|
||||||
import 'package:cherry_toast/resources/arrays.dart';
|
import 'package:cherry_toast/resources/arrays.dart';
|
||||||
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
|
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@ class Notificator {
|
|||||||
return AlertImpl().showConfirm(
|
return AlertImpl().showConfirm(
|
||||||
windowTitle: title,
|
windowTitle: title,
|
||||||
text: description,
|
text: description,
|
||||||
positiveButtonTitle: positiveButtonTitle,
|
positiveButtonTitle: positiveButtonTitle ?? LocaleKeys.str_yes.tr(),
|
||||||
negativeButtonTitle: negativeButtonTitle,
|
negativeButtonTitle: negativeButtonTitle ?? LocaleKeys.str_no.tr(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ class DButton extends StatefulWidget {
|
|||||||
message: LocaleKeys.operation_paste.tr(),
|
message: LocaleKeys.operation_paste.tr(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
factory DButton.logout({
|
||||||
|
Key? key,
|
||||||
|
required enabled,
|
||||||
|
GestureTapCallback? onTap,
|
||||||
|
height = defaultButtonHeight,
|
||||||
|
width = defaultButtonHeight,
|
||||||
|
}) =>
|
||||||
|
DButton(
|
||||||
|
key: key,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
child: Tooltip(
|
||||||
|
child: Opacity(opacity: enabled ? 1 : 0.4, child: const Icon(Icons.logout_rounded, size: 20)),
|
||||||
|
message: LocaleKeys.operation_logout.tr(),
|
||||||
|
));
|
||||||
|
|
||||||
factory DButton.dropdown({
|
factory DButton.dropdown({
|
||||||
Key? key,
|
Key? key,
|
||||||
width = 60.0,
|
width = 60.0,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:dde_gesture_manager/constants/constants.dart';
|
import 'package:dde_gesture_manager/constants/constants.dart';
|
||||||
import 'package:dde_gesture_manager/extensions.dart';
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
import 'package:dde_gesture_manager/models/settings.provider.dart';
|
import 'package:dde_gesture_manager/models/settings.provider.dart';
|
||||||
|
import 'package:dde_gesture_manager/utils/notificator.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:markdown_editor_ot/markdown_editor.dart';
|
import 'package:markdown_editor_ot/markdown_editor.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -77,11 +78,18 @@ class _DMarkdownFieldState extends State<DMarkdownField> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor: widget.readOnly ? SystemMouseCursors.basic : SystemMouseCursors.click,
|
cursor: widget.readOnly ? SystemMouseCursors.basic : SystemMouseCursors.text,
|
||||||
child: MdPreview(
|
child: MdPreview(
|
||||||
text: _previewText ?? '',
|
text: _previewText ?? '',
|
||||||
padding: EdgeInsets.only(left: 15),
|
padding: EdgeInsets.only(left: 15),
|
||||||
onTapLink: _launchURL,
|
onTapLink: _launchURL,
|
||||||
|
onCodeCopied: () {
|
||||||
|
Notificator.success(
|
||||||
|
context,
|
||||||
|
title: LocaleKeys.info_code_copied_titte.tr(),
|
||||||
|
description: LocaleKeys.info_code_copied_description.tr(),
|
||||||
|
);
|
||||||
|
},
|
||||||
widgetImage: (imageUrl) => CachedNetworkImage(
|
widgetImage: (imageUrl) => CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
placeholder: (context, url) => const SizedBox(
|
placeholder: (context, url) => const SizedBox(
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dde_gesture_manager/constants/sp_keys.dart';
|
||||||
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
|
import 'package:dde_gesture_manager/http/api.dart';
|
||||||
|
import 'package:dde_gesture_manager/models/configs.provider.dart';
|
||||||
|
import 'package:dde_gesture_manager/models/settings.provider.dart';
|
||||||
|
import 'package:dde_gesture_manager/utils/helper.dart';
|
||||||
|
import 'package:dde_gesture_manager/utils/notificator.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_login/flutter_login.dart';
|
||||||
|
|
||||||
|
class LoginWidget extends StatefulWidget {
|
||||||
|
const LoginWidget({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_LoginWidgetState createState() => _LoginWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginWidgetState extends State<LoginWidget> {
|
||||||
|
ValueKey<int> _key = ValueKey(0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: OverflowBox(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Container(
|
||||||
|
child: ScrollConfiguration(
|
||||||
|
behavior: const ScrollBehavior().copyWith(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
),
|
||||||
|
child: FlutterLogin(
|
||||||
|
key: _key,
|
||||||
|
onLogin: (loginData) async {
|
||||||
|
try {
|
||||||
|
var res = await Api.loginOrSignup(email: loginData.name, password: loginData.password);
|
||||||
|
if (res != null && res.token.notNull)
|
||||||
|
context.read<ConfigsProvider>().setProps(accessToken: res.token, email: loginData.name);
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e is HttpErrorCode)) return;
|
||||||
|
var code = e.statusCode;
|
||||||
|
if (code == HttpStatus.unauthorized)
|
||||||
|
Notificator.error(
|
||||||
|
context,
|
||||||
|
title: LocaleKeys.info_login_failed_title.tr(),
|
||||||
|
description: LocaleKeys.info_login_failed_description.tr(),
|
||||||
|
);
|
||||||
|
else if (code == HttpStatus.notFound)
|
||||||
|
Notificator.info(
|
||||||
|
context,
|
||||||
|
title: LocaleKeys.info_sign_up_hint_title.tr(),
|
||||||
|
description: LocaleKeys.info_sign_up_hint_description.tr(),
|
||||||
|
);
|
||||||
|
else
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSubmitAnimationCompleted: () {
|
||||||
|
var token = H().sp.getString(SPKeys.accessToken);
|
||||||
|
if (token.isNull)
|
||||||
|
setState(() {
|
||||||
|
_key = ValueKey(_key.value + 1);
|
||||||
|
});
|
||||||
|
else if (context.read<ConfigsProvider>().accessToken != token)
|
||||||
|
context
|
||||||
|
.read<ConfigsProvider>()
|
||||||
|
.setProps(accessToken: token, email: H().sp.getString(SPKeys.loginEmail));
|
||||||
|
},
|
||||||
|
onRecoverPassword: (_) {},
|
||||||
|
hideForgotPasswordButton: true,
|
||||||
|
disableCustomPageTransformer: true,
|
||||||
|
messages: LoginMessages(
|
||||||
|
userHint: LocaleKeys.me_login_email_hint.tr(),
|
||||||
|
passwordHint: LocaleKeys.me_login_password_hint.tr(),
|
||||||
|
loginButton: LocaleKeys.me_login_login_or_signup.tr(),
|
||||||
|
),
|
||||||
|
userValidator: (value) {
|
||||||
|
if (FlutterLogin.defaultEmailValidator(value) != null) {
|
||||||
|
return LocaleKeys.me_login_email_error_hint.tr();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
passwordValidator: (value) {
|
||||||
|
if (value!.isEmpty || value.length < 8 || value.length > 16) {
|
||||||
|
return LocaleKeys.me_login_password_hint.tr();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
theme: LoginTheme(
|
||||||
|
pageColorDark: Colors.transparent,
|
||||||
|
pageColorLight: Colors.transparent,
|
||||||
|
primaryColor: context.watch<SettingsProvider>().currentActiveColor,
|
||||||
|
footerBackgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:dde_gesture_manager/extensions.dart';
|
import 'package:dde_gesture_manager/extensions.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:dde_gesture_manager/http/api.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:dde_gesture_manager/utils/notificator.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:dde_gesture_manager_api/apis.dart';
|
import 'package:dde_gesture_manager_api/apis.dart';
|
||||||
import 'package:dde_gesture_manager_api/models.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class VersionChecker extends StatelessWidget {
|
class VersionChecker extends StatelessWidget {
|
||||||
const VersionChecker({Key? key}) : super(key: key);
|
const VersionChecker({Key? key}) : super(key: key);
|
||||||
@@ -22,20 +23,31 @@ class VersionChecker extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'${LocaleKeys.version_current.tr()} : ${snapshot.data?.version ?? ''}',
|
'${LocaleKeys.version_current.tr()} : ${snapshot.data?.version ?? ''}',
|
||||||
),
|
),
|
||||||
Padding(
|
if (!kIsWeb)
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
Padding(
|
||||||
child: TextButton(
|
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
child: Text(LocaleKeys.version_check_update).tr(),
|
child: TextButton(
|
||||||
onPressed: () {
|
child: Text(LocaleKeys.version_check_update).tr(),
|
||||||
http.get(Uri.parse('http://127.0.0.1:3000' + Apis.system.appVersion)).then((value) {
|
onPressed: () {
|
||||||
var appVersion = AppVersionSerializer.fromMap(json.decode(value.body));
|
Api.checkAppVersion().then((value) {
|
||||||
appVersion.versionName.sout();
|
if (value != null && (value.versionCode ?? 0) > int.parse(snapshot.data?.buildNumber ?? '0')) {
|
||||||
appVersion.versionCode.sout();
|
Notificator.showConfirm(
|
||||||
appVersion.sout();
|
title: LocaleKeys.info_new_version_title.tr(namedArgs: {'version': '${value.versionName}'}),
|
||||||
});
|
description: LocaleKeys.info_new_version_description_for_manual.tr(),
|
||||||
},
|
).then((value) async {
|
||||||
|
if (value == CustomButton.positiveButton) {
|
||||||
|
if (await canLaunch(Apis.appNewVersionUrl)) {
|
||||||
|
await launch(Apis.appNewVersionUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Notificator.info(context, title: LocaleKeys.info_new_version_title_already_latest.tr());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ dependencies:
|
|||||||
flutter_platform_alert: ^0.2.1
|
flutter_platform_alert: ^0.2.1
|
||||||
cached_network_image: ^3.2.0
|
cached_network_image: ^3.2.0
|
||||||
url_launcher: ^6.0.17
|
url_launcher: ^6.0.17
|
||||||
|
flutter_login: ^3.1.0
|
||||||
|
auto_size_text: ^3.0.0
|
||||||
markdown_editor_ot:
|
markdown_editor_ot:
|
||||||
path: 3rd_party/markdown_editor_ot
|
path: 3rd_party/markdown_editor_ot
|
||||||
cherry_toast:
|
cherry_toast:
|
||||||
|
|||||||
@@ -70,12 +70,15 @@
|
|||||||
"delete": "delete",
|
"delete": "delete",
|
||||||
"duplicate": "duplicate",
|
"duplicate": "duplicate",
|
||||||
"apply": "apply",
|
"apply": "apply",
|
||||||
"paste": "paste"
|
"paste": "paste",
|
||||||
|
"logout": "sign out"
|
||||||
},
|
},
|
||||||
"str": {
|
"str": {
|
||||||
"null": "Null",
|
"null": "Null",
|
||||||
"new_scheme": "New gesture scheme",
|
"new_scheme": "New gesture scheme",
|
||||||
"copy": "copy"
|
"copy": "copy",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
},
|
},
|
||||||
"built_in_commands": {
|
"built_in_commands": {
|
||||||
"ShowWorkspace": "ShowWorkspace",
|
"ShowWorkspace": "ShowWorkspace",
|
||||||
@@ -101,6 +104,37 @@
|
|||||||
"scheme_name_conflict": {
|
"scheme_name_conflict": {
|
||||||
"title": "Save failed!",
|
"title": "Save failed!",
|
||||||
"description": "Scheme name conflict, please rename it!"
|
"description": "Scheme name conflict, please rename it!"
|
||||||
|
},
|
||||||
|
"code_copied": {
|
||||||
|
"titte": "Code has been copied!",
|
||||||
|
"description": "Please paste it into the terminal and run ~"
|
||||||
|
},
|
||||||
|
"server_error": {
|
||||||
|
"title": "Server connection error",
|
||||||
|
"description": "Please try again later ~"
|
||||||
|
},
|
||||||
|
"login_failed": {
|
||||||
|
"title": "Login failed",
|
||||||
|
"description": "Please check the password ~"
|
||||||
|
},
|
||||||
|
"sign_up_hint": {
|
||||||
|
"title": "Verification code has been sent",
|
||||||
|
"description": "Please log in to the mailbox to continue the registration ~"
|
||||||
|
},
|
||||||
|
"new_version": {
|
||||||
|
"title": "Update found(v{version})",
|
||||||
|
"description_for_startup": "Click [{YES}] to view, click [{NO}] ignore this update",
|
||||||
|
"title_already_latest": "Already the latest version ~",
|
||||||
|
"description_for_manual": "Visit the official website to see more?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"me": {
|
||||||
|
"title": "Me",
|
||||||
|
"login": {
|
||||||
|
"login_or_signup": "Login/Signup",
|
||||||
|
"email_hint": "Please enter email",
|
||||||
|
"password_hint": "Please enter 8-16-bit password",
|
||||||
|
"email_error_hint": "Please enter your vaild email"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,12 +70,15 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"duplicate": "复制",
|
"duplicate": "复制",
|
||||||
"apply": "应用",
|
"apply": "应用",
|
||||||
"paste": "粘贴"
|
"paste": "粘贴",
|
||||||
|
"logout": "退出登录"
|
||||||
},
|
},
|
||||||
"str": {
|
"str": {
|
||||||
"null": "无",
|
"null": "无",
|
||||||
"new_scheme": "新建手势方案",
|
"new_scheme": "新建手势方案",
|
||||||
"copy": "副本"
|
"copy": "副本",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否"
|
||||||
},
|
},
|
||||||
"built_in_commands": {
|
"built_in_commands": {
|
||||||
"ShowWorkspace": "显示工作区",
|
"ShowWorkspace": "显示工作区",
|
||||||
@@ -101,6 +104,37 @@
|
|||||||
"scheme_name_conflict": {
|
"scheme_name_conflict": {
|
||||||
"title": "保存失败!",
|
"title": "保存失败!",
|
||||||
"description": "方案名冲突,请重新命名!"
|
"description": "方案名冲突,请重新命名!"
|
||||||
|
},
|
||||||
|
"code_copied": {
|
||||||
|
"titte": "代码已复制!",
|
||||||
|
"description": "请粘贴到终端中执行~"
|
||||||
|
},
|
||||||
|
"server_error": {
|
||||||
|
"title": "连接服务器异常",
|
||||||
|
"description": "请稍后再试~"
|
||||||
|
},
|
||||||
|
"login_failed": {
|
||||||
|
"title": "登录失败",
|
||||||
|
"description": "请检查账号密码是否正确~"
|
||||||
|
},
|
||||||
|
"sign_up_hint": {
|
||||||
|
"title": "验证码已发送",
|
||||||
|
"description": "请登录邮箱继续完成注册~"
|
||||||
|
},
|
||||||
|
"new_version": {
|
||||||
|
"title": "发现新版本(v{version})",
|
||||||
|
"description_for_startup": "点击[{yes}]查看,点击[{no}]忽略本次更新",
|
||||||
|
"title_already_latest": "已经是最新版本~",
|
||||||
|
"description_for_manual": "是否前去官网查看?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"me": {
|
||||||
|
"title": "我的",
|
||||||
|
"login": {
|
||||||
|
"login_or_signup": "登录/注册",
|
||||||
|
"email_hint": "请输入邮箱",
|
||||||
|
"password_hint": "请输入8-16位密码",
|
||||||
|
"email_error_hint": "请输入正确的邮箱"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
flutter packages pub get
|
flutter packages pub get
|
||||||
flutter packages pub run build_runner build --delete-conflicting-outputs
|
|
||||||
flutter pub run easy_localization:generate
|
flutter pub run easy_localization:generate
|
||||||
flutter pub run easy_localization:generate -f keys -o locale_keys.g.dart
|
flutter pub run easy_localization:generate -f keys -o locale_keys.g.dart
|
||||||
|
flutter packages pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|||||||
Reference in New Issue
Block a user