feat: implement some api; add md editor to app; login and signup logic.

pull/5/head
DebuggerX 3 years ago
parent 1a0e8f8de7
commit 853132f1a8

@ -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.

@ -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.

@ -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;
}

@ -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();
}

@ -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

@ -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"

@ -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);
}

@ -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());

@ -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 %}

@ -0,0 +1,3 @@
## 1.0.0
* first version

@ -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.

@ -0,0 +1,17 @@
# markdown_core
Parse markdown and render it into rich text.
![show](https://xia-weiyang.github.io/gif/markdown_core.gif)
``` dart
Markdown(
data: markdownDataString,
linkTap: (link) => print('点击了链接 $link'),
textStyle: // your text style ,
image: (imageUrl) {
print('imageUrl $imageUrl');
return // Your image widget ;
},
)
```

@ -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('&gt;', '>');
content = content.replaceAll('&lt;', '<');
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);

@ -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.giinaltHubWeb,
// ));
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);
}
}

@ -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;
}

@ -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,
); );
}, },
), ),

@ -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();
}
}

@ -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:dde_gesture_manager/http/api.dart';
import 'package:dde_gesture_manager/utils/notificator.dart';
import 'package:dde_gesture_manager_api/apis.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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:package_info_plus/package_info_plus.dart';
import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart';
import 'package:dde_gesture_manager_api/apis.dart';
import 'package:dde_gesture_manager_api/models.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": "请输入正确的邮箱"
} }
} }
} }

@ -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

Loading…
Cancel
Save