From 853132f1a8f1392abab591184d5614a38383a865 Mon Sep 17 00:00:00 2001 From: debuggerx Date: Thu, 30 Dec 2021 20:04:00 +0800 Subject: [PATCH] feat: implement some api; add md editor to app; login and signup logic. --- api/3rd_party/neat_cache/CHANGELOG.md | 15 + api/3rd_party/neat_cache/LICENSE | 202 ++++++++ api/3rd_party/neat_cache/README.md | 39 ++ api/3rd_party/neat_cache/analysis_options.yaml | 1 + api/3rd_party/neat_cache/lib/cache_provider.dart | 77 +++ api/3rd_party/neat_cache/lib/neat_cache.dart | 228 +++++++++ .../neat_cache/lib/src/providers/inmemory.dart | 110 +++++ .../neat_cache/lib/src/providers/redis.dart | 210 ++++++++ .../neat_cache/lib/src/providers/resp.dart | 526 +++++++++++++++++++++ api/3rd_party/neat_cache/pubspec.yaml | 18 + api/analysis_options.yaml | 1 + api/bin/migrate.dart | 35 +- api/config/default.yaml | 3 +- api/lib/apis.dart | 94 +++- api/lib/models.dart | 3 +- api/lib/src/config/plugins/jwt.dart | 16 + api/lib/src/config/plugins/plugins.dart | 4 + api/lib/src/config/plugins/redis_cache.dart | 36 ++ api/lib/src/models/login_success.dart | 9 + api/lib/src/models/user.dart | 13 +- .../src/routes/controllers/auth_controllers.dart | 78 +++ .../routes/controllers/controller_extensions.dart | 16 + api/lib/src/routes/controllers/middlewares.dart | 35 ++ .../src/routes/controllers/user_controllers.dart | 16 - api/lib/src/routes/routes.dart | 12 +- api/pubspec.yaml | 6 +- api/source_gen.sh | 4 + api/views/confirm_sign_up.html | 8 + api/views/sign_up_result.html | 16 + app/3rd_party/markdown_core/CHANGELOG.md | 3 + app/3rd_party/markdown_core/LICENSE | 201 ++++++++ app/3rd_party/markdown_core/README.md | 17 + app/3rd_party/markdown_core/lib/builder.dart | 340 +++++++++++++ app/3rd_party/markdown_core/lib/markdown.dart | 67 +++ app/3rd_party/markdown_core/lib/text_style.dart | 107 +++++ app/3rd_party/markdown_core/pubspec.yaml | 56 +++ .../markdown_editor_ot/lib/src/preview.dart | 4 + app/3rd_party/markdown_editor_ot/pubspec.yaml | 4 +- app/lib/constants/constants.dart | 5 +- app/lib/constants/sp_keys.dart | 3 + app/lib/extensions/string_extension.dart | 2 + app/lib/http/api.dart | 135 ++++++ app/lib/models/configs.dart | 41 +- app/lib/models/content_layout.dart | 7 +- app/lib/pages/content.dart | 8 +- app/lib/pages/gesture_editor.dart | 6 +- app/lib/pages/local_manager.dart | 3 - app/lib/pages/market.dart | 65 --- app/lib/pages/market_or_me.dart | 117 +++++ app/lib/utils/helper.dart | 30 +- app/lib/utils/init_linux.dart | 35 ++ app/lib/utils/init_web.dart | 1 + app/lib/utils/notificator.dart | 5 +- app/lib/widgets/dde_button.dart | 17 + app/lib/widgets/dde_markdown_field.dart | 10 +- app/lib/widgets/login.dart | 102 ++++ app/lib/widgets/version_checker.dart | 48 +- app/pubspec.yaml | 2 + app/resources/langs/en.json | 38 +- app/resources/langs/zh-CN.json | 38 +- app/source_gen.sh | 4 +- 61 files changed, 3204 insertions(+), 148 deletions(-) create mode 100644 api/3rd_party/neat_cache/CHANGELOG.md create mode 100644 api/3rd_party/neat_cache/LICENSE create mode 100644 api/3rd_party/neat_cache/README.md create mode 100644 api/3rd_party/neat_cache/analysis_options.yaml create mode 100644 api/3rd_party/neat_cache/lib/cache_provider.dart create mode 100644 api/3rd_party/neat_cache/lib/neat_cache.dart create mode 100644 api/3rd_party/neat_cache/lib/src/providers/inmemory.dart create mode 100644 api/3rd_party/neat_cache/lib/src/providers/redis.dart create mode 100644 api/3rd_party/neat_cache/lib/src/providers/resp.dart create mode 100644 api/3rd_party/neat_cache/pubspec.yaml create mode 100644 api/lib/src/config/plugins/jwt.dart create mode 100644 api/lib/src/config/plugins/redis_cache.dart create mode 100644 api/lib/src/models/login_success.dart create mode 100644 api/lib/src/routes/controllers/auth_controllers.dart create mode 100644 api/lib/src/routes/controllers/middlewares.dart delete mode 100644 api/lib/src/routes/controllers/user_controllers.dart create mode 100644 api/source_gen.sh create mode 100644 api/views/confirm_sign_up.html create mode 100644 api/views/sign_up_result.html create mode 100644 app/3rd_party/markdown_core/CHANGELOG.md create mode 100644 app/3rd_party/markdown_core/LICENSE create mode 100644 app/3rd_party/markdown_core/README.md create mode 100644 app/3rd_party/markdown_core/lib/builder.dart create mode 100644 app/3rd_party/markdown_core/lib/markdown.dart create mode 100644 app/3rd_party/markdown_core/lib/text_style.dart create mode 100644 app/3rd_party/markdown_core/pubspec.yaml create mode 100644 app/lib/http/api.dart delete mode 100644 app/lib/pages/market.dart create mode 100644 app/lib/pages/market_or_me.dart create mode 100644 app/lib/widgets/login.dart diff --git a/api/3rd_party/neat_cache/CHANGELOG.md b/api/3rd_party/neat_cache/CHANGELOG.md new file mode 100644 index 0000000..9e14748 --- /dev/null +++ b/api/3rd_party/neat_cache/CHANGELOG.md @@ -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. diff --git a/api/3rd_party/neat_cache/LICENSE b/api/3rd_party/neat_cache/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/api/3rd_party/neat_cache/LICENSE @@ -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. \ No newline at end of file diff --git a/api/3rd_party/neat_cache/README.md b/api/3rd_party/neat_cache/README.md new file mode 100644 index 0000000..7dff0ca --- /dev/null +++ b/api/3rd_party/neat_cache/README.md @@ -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 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` diff --git a/api/3rd_party/neat_cache/analysis_options.yaml b/api/3rd_party/neat_cache/analysis_options.yaml new file mode 100644 index 0000000..108d105 --- /dev/null +++ b/api/3rd_party/neat_cache/analysis_options.yaml @@ -0,0 +1 @@ +include: package:pedantic/analysis_options.yaml diff --git a/api/3rd_party/neat_cache/lib/cache_provider.dart b/api/3rd_party/neat_cache/lib/cache_provider.dart new file mode 100644 index 0000000..77abe4e --- /dev/null +++ b/api/3rd_party/neat_cache/lib/cache_provider.dart @@ -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>` 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 { + /// Fetch data stored under [key]. + /// + /// If nothing is cached for [key], this **must** return `null`. + Future 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 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 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 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; +} diff --git a/api/3rd_party/neat_cache/lib/neat_cache.dart b/api/3rd_party/neat_cache/lib/neat_cache.dart new file mode 100644 index 0000000..5f029c1 --- /dev/null +++ b/api/3rd_party/neat_cache/lib/neat_cache.dart @@ -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] wrapper that can be used to get/set data cached at given key. +/// +/// **Example** +/// ```dart +/// final Cache> 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 { + /// Get [Entry] wrapping data cached at [key]. + Entry operator [](String key); + + /// Get a [Cache] wrapping of this cache with given [prefix]. + Cache withPrefix(String prefix); + + /// Get a [Cache] wrapping of this cache by encoding objects of type [S] as + /// [T] using the given [codec]. + Cache withCodec(Codec 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 withTTL(Duration ttl); + + /// Create a [Cache] wrapping a [CacheProvider]. + factory Cache(CacheProvider provider) { + return _Cache(provider, '', IdentityCodec()); + } + + /// Create an in-memory [CacheProvider] holding a maximum of [maxSize] cache + /// entries. + static CacheProvider> inMemoryCacheProvider(int maxSize) { + return InMemoryCacheProvider(maxSize); + } + + /// Create a redis [CacheProvider] by connecting using a [connectionString] on + /// the form `redis://:`. + static CacheProvider> 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 { + /// 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 get([Future 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 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 implements Cache { + final CacheProvider _provider; + final String _prefix; + final Codec _codec; + final Duration? _ttl; + + _Cache(this._provider, this._prefix, this._codec, [this._ttl]); + + @override + Entry operator [](String key) => _Entry(this, _prefix + key); + + @override + Cache withPrefix(String prefix) => + _Cache(_provider, _prefix + prefix, _codec, _ttl); + + @override + Cache withCodec(Codec codec) => + _Cache(_provider, _prefix, codec.fuse(_codec), _ttl); + + @override + Cache withTTL(Duration ttl) => _Cache(_provider, _prefix, _codec, ttl); +} + +class _Entry implements Entry { + final _Cache _owner; + final String _key; + _Entry(this._owner, this._key); + + @override + Future get([Future 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 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 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, + ); + } +} diff --git a/api/3rd_party/neat_cache/lib/src/providers/inmemory.dart b/api/3rd_party/neat_cache/lib/src/providers/inmemory.dart new file mode 100644 index 0000000..45e1347 --- /dev/null +++ b/api/3rd_party/neat_cache/lib/src/providers/inmemory.dart @@ -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 { + 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 extends CacheProvider { + /// New generation of cache entries. + Map> _new = >{}; + + /// Old generation of cache entries. + Map> _old = >{}; + + /// 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 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 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 purge(String key) async { + if (_isClosed) { + throw StateError('CacheProvider.close() have been called'); + } + _new.remove(key); + _old.remove(key); + } + + @override + Future close() async { + _isClosed = true; + _old = {}; + _new = {}; + } +} diff --git a/api/3rd_party/neat_cache/lib/src/providers/redis.dart b/api/3rd_party/neat_cache/lib/src/providers/redis.dart new file mode 100644 index 0000000..0480d88 --- /dev/null +++ b/api/3rd_party/neat_cache/lib/src/providers/redis.dart @@ -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> { + 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 _withResp(Future 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 close() async { + _isClosed = true; + if (_context != null) { + try { + final ctx = await _context!; + await ctx.client.close(); + } catch (e) { + // ignore + } + } + } + + @override + Future?> 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 set(String key, List value, [Duration? ttl]) => + _withResp((client) async { + final r = await client.command([ + 'SET', + key, + value, + if (ttl != null) ...['EX', ttl.inSeconds], + ]); + if (r != 'OK') { + assert(false, 'unexpected response from redis server'); + + // Force close the client + scheduleMicrotask(() => client.close(force: true)); + } + }); + + @override + Future 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)); + } + }); +} diff --git a/api/3rd_party/neat_cache/lib/src/providers/resp.dart b/api/3rd_party/neat_cache/lib/src/providers/resp.dart new file mode 100644 index 0000000..0883d60 --- /dev/null +++ b/api/3rd_party/neat_cache/lib/src/providers/resp.dart @@ -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> _output; + Future _pendingStream = Future.value(null); + + final _pending = Queue>(); + + bool _closing = false; + final _closed = Completer(); + + /// 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] and [StreamSink>]. 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 input, + StreamSink> 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 get closed => _closed.future; + + /// Send command to redis and return the result. + /// + /// The [args] is a list of: + /// * [String], + /// * [List], 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], 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 command(List 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 bytes; + if (arg is String) { + bytes = utf8.encode(arg); + } else if (arg is List) { + 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', + ); + } + + out.addByte(r'$'.codeUnitAt(0)); + out.add(ascii.encode(bytes.length.toString())); + out.add(_newLine); + out.add(bytes); + out.add(_newLine); + } + + final c = Completer(); + _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 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 _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 _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 = []; + 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 _input; + Uint8List _buffer = _emptyList; + + _ByteStreamScanner(Stream stream) + : _input = StreamIterator(stream); + + /// Read a single byte, return zero if stream has ended. + Future 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 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 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 cancel() async => await _input.cancel(); +} diff --git a/api/3rd_party/neat_cache/pubspec.yaml b/api/3rd_party/neat_cache/pubspec.yaml new file mode 100644 index 0000000..e5097a2 --- /dev/null +++ b/api/3rd_party/neat_cache/pubspec.yaml @@ -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' diff --git a/api/analysis_options.yaml b/api/analysis_options.yaml index d66fc45..89ea25f 100644 --- a/api/analysis_options.yaml +++ b/api/analysis_options.yaml @@ -4,3 +4,4 @@ linter: rules: avoid_renaming_method_parameters: false overridden_fields: false + unnecessary_statements: true diff --git a/api/bin/migrate.dart b/api/bin/migrate.dart index 5c5e413..8963f56 100644 --- a/api/bin/migrate.dart +++ b/api/bin/migrate.dart @@ -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/models.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:logging/logging.dart'; +late Map configuration; + void main(List args) async { // Enable the logging Logger.root.level = Level.INFO; @@ -19,10 +23,39 @@ void main(List args) async { }); var fs = LocalFileSystem(); - var configuration = await loadStandaloneConfiguration(fs); + configuration = await loadStandaloneConfiguration(fs); var connection = await connectToPostgres(configuration); var migrationRunner = PostgresMigrationRunner(connection, migrations: [ UserMigration(), + UserSeed(), ]); 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(); +} diff --git a/api/config/default.yaml b/api/config/default.yaml index 16dd769..8ee46d8 100644 --- a/api/config/default.yaml +++ b/api/config/default.yaml @@ -9,4 +9,5 @@ postgres: password: App1970# useSSL: false time_zone: UTC -jwt_secret: "OvA9SBLnncot8gFHvt8Gh1qkQ1ptGIQW" \ No newline at end of file +jwt_secret: "OvA9SBLnncot8gFHvt8Gh1qkQ1ptGIQW" +password_salt: "test" \ No newline at end of file diff --git a/api/lib/apis.dart b/api/lib/apis.dart index e309e7b..3f1ba2f 100644 --- a/api/lib/apis.dart +++ b/api/lib/apis.dart @@ -1,8 +1,96 @@ 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 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 { - static final String _path = '/system'; - String get appVersion => _path + '/app-version'; -} \ No newline at end of file + static final String path = '/system'; + + 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 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); +} diff --git a/api/lib/models.dart b/api/lib/models.dart index e604c02..c608c7a 100644 --- a/api/lib/models.dart +++ b/api/lib/models.dart @@ -1,2 +1,3 @@ export 'src/models/user.dart'; -export 'src/models/app_version.dart'; \ No newline at end of file +export 'src/models/app_version.dart'; +export 'src/models/login_success.dart'; \ No newline at end of file diff --git a/api/lib/src/config/plugins/jwt.dart b/api/lib/src/config/plugins/jwt.dart new file mode 100644 index 0000000..8cf8700 --- /dev/null +++ b/api/lib/src/config/plugins/jwt.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 configureServer(Angel app) async { + var auth = AngelAuth( + jwtKey: app.configuration['jwt_secret'], + allowCookie: false, + deserializer: (p) async => (UserQuery()..where!.id.equals(int.parse(p))) + .getOne(app.container!.make()) + .then((value) => value.value), + serializer: (p) => p.id ?? '', + ); + await auth.configureServer(app); +} diff --git a/api/lib/src/config/plugins/plugins.dart b/api/lib/src/config/plugins/plugins.dart index acda711..a8e18dd 100644 --- a/api/lib/src/config/plugins/plugins.dart +++ b/api/lib/src/config/plugins/plugins.dart @@ -1,8 +1,12 @@ import 'dart:async'; import 'package:angel3_framework/angel3_framework.dart'; import 'orm.dart' as orm; +import 'jwt.dart' as jwt; +import 'redis_cache.dart' as redis_cache; Future configureServer(Angel app) async { // Include any plugins you have made here. await app.configure(orm.configureServer); + await app.configure(jwt.configureServer); + await app.configure(redis_cache.configureServer); } diff --git a/api/lib/src/config/plugins/redis_cache.dart b/api/lib/src/config/plugins/redis_cache.dart new file mode 100644 index 0000000..5ce6962 --- /dev/null +++ b/api/lib/src/config/plugins/redis_cache.dart @@ -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 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); + } +} diff --git a/api/lib/src/models/login_success.dart b/api/lib/src/models/login_success.dart new file mode 100644 index 0000000..a553682 --- /dev/null +++ b/api/lib/src/models/login_success.dart @@ -0,0 +1,9 @@ +import 'package:angel3_serialize/angel3_serialize.dart'; + +part 'login_success.g.dart'; + +@serializable +class _LoginSuccess { + @SerializableField(isNullable: false) + String? token; +} \ No newline at end of file diff --git a/api/lib/src/models/user.dart b/api/lib/src/models/user.dart index 1288a20..c34c7ef 100644 --- a/api/lib/src/models/user.dart +++ b/api/lib/src/models/user.dart @@ -1,19 +1,24 @@ +import 'dart:convert'; + import 'package:angel3_migration/angel3_migration.dart'; import 'package:angel3_serialize/angel3_serialize.dart'; import 'package:angel3_orm/angel3_orm.dart'; import 'package:dde_gesture_manager_api/src/models/base_model.dart'; import 'package:optional/optional.dart'; +import 'package:crypto/crypto.dart'; + part 'user.g.dart'; @serializable @orm abstract class _User extends BaseModel { + @Column(isNullable: false, indexType: IndexType.unique) @SerializableField(isNullable: false) String? get email; - @SerializableField(isNullable: false) + @Column(isNullable: false, length: 32) + @SerializableField(isNullable: true, exclude: true) String? get password; - @SerializableField(isNullable: false) - String? get token; -} \ No newline at end of file + String secret(String salt) => base64.encode(Hmac(sha256, salt.codeUnits).convert((password ?? '').codeUnits).bytes); +} diff --git a/api/lib/src/routes/controllers/auth_controllers.dart b/api/lib/src/routes/controllers/auth_controllers.dart new file mode 100644 index 0000000..aae33e1 --- /dev/null +++ b/api/lib/src/routes/controllers/auth_controllers.dart @@ -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(); + await angelAuth.loginById(user.value.id!, req, res); + var authToken = req.container!.make(); + 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}); + }); +} diff --git a/api/lib/src/routes/controllers/controller_extensions.dart b/api/lib/src/routes/controllers/controller_extensions.dart index f0f6007..f13140e 100644 --- a/api/lib/src/routes/controllers/controller_extensions.dart +++ b/api/lib/src/routes/controllers/controller_extensions.dart @@ -2,12 +2,24 @@ import 'dart:io'; import 'package:angel3_framework/angel3_framework.dart'; 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 { noContent() { statusCode = HttpStatus.noContent; return close(); } + + notFound() { + statusCode = HttpStatus.notFound; + return close(); + } + + unauthorized() { + statusCode = HttpStatus.unauthorized; + return close(); + } } extension QueryWhereId on orm.Query { @@ -19,3 +31,7 @@ extension QueryWhereId on orm.Query { extension QueryExecutor on RequestContext { orm.QueryExecutor get queryExecutor => container!.make(); } + +extension RedisExecutor on RequestContext { + Cache get cache => container!.make().cache; +} diff --git a/api/lib/src/routes/controllers/middlewares.dart b/api/lib/src/routes/controllers/middlewares.dart new file mode 100644 index 0000000..84c9b63 --- /dev/null +++ b/api/lib/src/routes/controllers/middlewares.dart @@ -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() || req.method == 'OPTIONS') { + return true; + } else if (reqContainer.has>()) { + User user = await reqContainer.makeAsync(); + var authToken = req.container!.make(); + 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); + } + }; +} diff --git a/api/lib/src/routes/controllers/user_controllers.dart b/api/lib/src/routes/controllers/user_controllers.dart deleted file mode 100644 index d98b1e3..0000000 --- a/api/lib/src/routes/controllers/user_controllers.dart +++ /dev/null @@ -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); - }, - ); -} diff --git a/api/lib/src/routes/routes.dart b/api/lib/src/routes/routes.dart index f1eec6c..4b86464 100644 --- a/api/lib/src/routes/routes.dart +++ b/api/lib/src/routes/routes.dart @@ -1,6 +1,6 @@ import 'package:angel3_framework/angel3_framework.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; /// Put your app routes here! @@ -11,9 +11,17 @@ import 'controllers/system_controllers.dart' as system_controllers; AngelConfigurer configureServer(FileSystem fileSystem) { 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. 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. app.fallback((req, res) => throw AngelHttpException.notFound()); diff --git a/api/pubspec.yaml b/api/pubspec.yaml index d4f6806..9831fbc 100644 --- a/api/pubspec.yaml +++ b/api/pubspec.yaml @@ -3,7 +3,7 @@ version: 1.0.0 description: An ORM starter application for Angel3 framework publish_to: none environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.15.0 <3.0.0' dependencies: angel3_auth: ^4.0.0 angel3_configuration: ^4.1.0 @@ -19,6 +19,10 @@ dependencies: optional: ^6.0.0 logging: ^1.0.0 yaml: ^3.1.0 + mailer: ^5.0.2 + uuid: ^3.0.5 + neat_cache: + path: 3rd_party/neat_cache dev_dependencies: angel3_hot: ^4.2.0 angel3_jinja: ^2.0.1 diff --git a/api/source_gen.sh b/api/source_gen.sh new file mode 100644 index 0000000..0903c08 --- /dev/null +++ b/api/source_gen.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +dart pub get +dart run build_runner build \ No newline at end of file diff --git a/api/views/confirm_sign_up.html b/api/views/confirm_sign_up.html new file mode 100644 index 0000000..3e1d60a --- /dev/null +++ b/api/views/confirm_sign_up.html @@ -0,0 +1,8 @@ +{% extends "layout.html" %} +{% block title %}确认注册{% endblock %} +{% block body %} +

确认注册

+

如果是您本人点击了《DDE手势管理器》的登录/注册按钮,并收到了本邮件,请点击下面的链接以完成注册,完成后请回到软件中使用之前填入的邮箱和密码登录账户。

+

如果您对上面的操作并不知情,则可能是其他用户错误使用了您的邮箱地址进行了注册,请无视本邮件,不要点击下面的链接,谢谢合作~

+ {{ confirm_url }} +{% endblock %} \ No newline at end of file diff --git a/api/views/sign_up_result.html b/api/views/sign_up_result.html new file mode 100644 index 0000000..e4fe969 --- /dev/null +++ b/api/views/sign_up_result.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} +{% block title %}注册结果{% endblock %} +{% block body %} +

注册结果

+{% if success %} +

注册成功~

+{% else %} +

注册失败..

+

可能是因为:

+
    +
  • 本链接已经超过三十分钟的有效期,请重新在软件中点击注册按钮
  • +
  • 本链接已经被点击并注册成功,请勿重复点击
  • +
  • 其他错误……非常抱歉,如果方便的话请通过邮件联系我~
  • +
+{% endif%} +{% endblock %} \ No newline at end of file diff --git a/app/3rd_party/markdown_core/CHANGELOG.md b/app/3rd_party/markdown_core/CHANGELOG.md new file mode 100644 index 0000000..4aeb4d7 --- /dev/null +++ b/app/3rd_party/markdown_core/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +* first version diff --git a/app/3rd_party/markdown_core/LICENSE b/app/3rd_party/markdown_core/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/app/3rd_party/markdown_core/LICENSE @@ -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. diff --git a/app/3rd_party/markdown_core/README.md b/app/3rd_party/markdown_core/README.md new file mode 100644 index 0000000..6c3cbaf --- /dev/null +++ b/app/3rd_party/markdown_core/README.md @@ -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 ; + }, +) +``` diff --git a/app/3rd_party/markdown_core/lib/builder.dart b/app/3rd_party/markdown_core/lib/builder.dart new file mode 100644 index 0000000..cff6d8e --- /dev/null +++ b/app/3rd_party/markdown_core/lib/builder.dart @@ -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 = []; + // int _level = 0; + List<_Element> _elementList = <_Element>[]; + + final TextStyle defaultTextStyle; + final TagTextStyle tagTextStyle; + + final BuildContext context; + final LinkTap linkTap; + final WidgetImage widgetImage; + final double maxWidth; + final Function onCodeCopied; + + @override + bool visitElementBefore(md.Element element) { + // _level++; + // debugPrint('visitElementBefore $_level ${element.textContent}'); + + String lastTag = ''; + if (_elementList.isNotEmpty) { + lastTag = _elementList.last.tag; + } + + var textStyle = tagTextStyle( + lastTag, + element.tag, + _elementList.isNotEmpty ? _elementList.last.textStyle : defaultTextStyle, + ); + + _elementList.add(_Element( + element.tag, + textStyle, + element.attributes, + )); + + return true; + } + + @override + void visitText(md.Text text) { + // debugPrint('text ${text.text}'); + + if (_elementList.isEmpty) return; + var last = _elementList.last; + last.textSpans ??= []; + + // 替换特定字符串 + var content = text.text.replaceAll('>', '>'); + content = content.replaceAll('<', '<'); + + if (last.tag == 'a') { + last.textSpans?.add(TextSpan( + text: content, + style: last.textStyle, + recognizer: TapGestureRecognizer() + ..onTap = () { + debugPrint(last.attributes.toString()); + linkTap(last.attributes['href'] ?? ''); + }, + )); + return; + } + + last.textSpans?.add(TextSpan( + text: content, + style: last.textStyle, + )); + } + + final padding = const EdgeInsets.fromLTRB(0, 5, 0, 5); + + String getTextFromElement(dynamic element) { + String result = ''; + if (element is List) { + result = element.map(getTextFromElement).join('\n'); + } else if (element is md.Element) { + result = result = element.children?.map(getTextFromElement).join('\n') ?? ''; + } else { + result = element.text; + } + return result; + } + + @override + void visitElementAfter(md.Element element) { + // debugPrint('visitElementAfter $_level ${element.tag}'); + // _level--; + + if (_elementList.isEmpty) return; + var last = _elementList.last; + _elementList.removeLast(); + var tempWidget; + if (kTextTags.indexOf(element.tag) != -1) { + if (_elementList.isNotEmpty && kTextParentTags.indexOf(_elementList.last.tag) != -1) { + // 内联标签处理 + _elementList.last.textSpans ??= []; + _elementList.last.textSpans?.addAll(last.textSpans ?? []); + } else { + if (last.textSpans?.isNotEmpty ?? false) { + tempWidget = SelectableText.rich( + TextSpan( + children: last.textSpans, + style: last.textStyle, + ), + ); + } + } + } else if ('li' == element.tag) { + tempWidget = _resolveToLi(last); + } else if ('pre' == element.tag) { + var preCode = HtmlUnescape().convert(getTextFromElement(element.children)); + tempWidget = _resolveToPre(last, preCode); + } else if ('blockquote' == element.tag) { + tempWidget = _resolveToBlockquote(last); + } else if ('img' == element.tag) { + if (_elementList.isNotEmpty && (_elementList.last.textSpans?.isNotEmpty ?? false)) { + _widgets.add( + Padding( + padding: padding, + child: RichText( + text: TextSpan( + children: _elementList.last.textSpans, + style: _elementList.last.textStyle, + ), + ), + ), + ); + _elementList.last.textSpans = null; + } + // debugPrint(element.attributes.toString()); + //_elementList.clear(); + _widgets.add( + Padding( + padding: padding, + child: widgetImage(element.attributes['src'] ?? ''), + ), + ); + } else if (last.widgets?.isNotEmpty ?? false) { + if (last.widgets?.length == 1) { + tempWidget = last.widgets?[0]; + } else { + tempWidget = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: last.widgets ?? [], + ); + } + } + + if (tempWidget != null) { + if (_elementList.isEmpty) { + _widgets.add( + Padding( + padding: padding, + child: tempWidget, + ), + ); + } else { + _elementList.last.widgets ??= []; + if (tempWidget is List) { + _elementList.last.widgets?.addAll(tempWidget); + } else { + _elementList.last.widgets?.add(tempWidget); + } + } + } + } + + List build(List 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 widgets = last.widgets ?? []; + List 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? widgets; + List? textSpans; + TextStyle textStyle; + Map attributes; +} + +/// 链接点击 +typedef void LinkTap(String link); + +typedef Widget WidgetImage(String imageUrl); + +typedef TextStyle TagTextStyle(String lastTag, String tag, TextStyle textStyle); diff --git a/app/3rd_party/markdown_core/lib/markdown.dart b/app/3rd_party/markdown_core/lib/markdown.dart new file mode 100644 index 0000000..5da7aad --- /dev/null +++ b/app/3rd_party/markdown_core/lib/markdown.dart @@ -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 { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _parseMarkdown(), + ); + } + + List _parseMarkdown() { + // debugPrint(markdownToHtml( + // widget.data, + // extensionSet: ExtensionSet.gi法inaltHubWeb, + // )); + final List 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); + } +} diff --git a/app/3rd_party/markdown_core/lib/text_style.dart b/app/3rd_party/markdown_core/lib/text_style.dart new file mode 100644 index 0000000..fe090a7 --- /dev/null +++ b/app/3rd_party/markdown_core/lib/text_style.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +const List kTextTags = const [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'code', + 'strong', + 'em', + 'del', + 'a', +]; + +const List kTextParentTags = const [ + '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; +} diff --git a/app/3rd_party/markdown_core/pubspec.yaml b/app/3rd_party/markdown_core/pubspec.yaml new file mode 100644 index 0000000..76579dc --- /dev/null +++ b/app/3rd_party/markdown_core/pubspec.yaml @@ -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 diff --git a/app/3rd_party/markdown_editor_ot/lib/src/preview.dart b/app/3rd_party/markdown_editor_ot/lib/src/preview.dart index b14babf..d93ca16 100644 --- a/app/3rd_party/markdown_editor_ot/lib/src/preview.dart +++ b/app/3rd_party/markdown_editor_ot/lib/src/preview.dart @@ -9,6 +9,7 @@ class MdPreview extends StatefulWidget { this.padding = const EdgeInsets.all(0.0), this.onTapLink, required this.widgetImage, + required this.onCodeCopied, this.textStyle, }) : super(key: key); @@ -17,6 +18,8 @@ class MdPreview extends StatefulWidget { final WidgetImage widgetImage; final TextStyle? textStyle; + final Function onCodeCopied; + /// Call this method when it tap link of markdown. /// If [onTapLink] is null,it will open the link with your default browser. final TapLinkCallback? onTapLink; @@ -46,6 +49,7 @@ class MdPreviewState extends State }, image: widget.widgetImage, textStyle: widget.textStyle, + onCodeCopied: widget.onCodeCopied, ); }, ), diff --git a/app/3rd_party/markdown_editor_ot/pubspec.yaml b/app/3rd_party/markdown_editor_ot/pubspec.yaml index 00f847b..253d1bd 100644 --- a/app/3rd_party/markdown_editor_ot/pubspec.yaml +++ b/app/3rd_party/markdown_editor_ot/pubspec.yaml @@ -11,7 +11,9 @@ dependencies: sdk: flutter shared_preferences: ^2.0.4 - markdown_core: ^1.0.0 + markdown_core: + path: + ../markdown_core dev_dependencies: flutter_test: diff --git a/app/lib/constants/constants.dart b/app/lib/constants/constants.dart index b57bd45..0a4ab78 100644 --- a/app/lib/constants/constants.dart +++ b/app/lib/constants/constants.dart @@ -1,10 +1,9 @@ import 'package:flutter/cupertino.dart'; /// [UOS设计指南](https://docs.uniontech.com/zh/content/t_dbG3kBK9iDf9B963ok) - const double localManagerPanelWidth = 260; -const double marketPanelWidth = 300; +const double marketOrMePanelWidth = 300; const shortDuration = const Duration(milliseconds: 100); @@ -39,5 +38,5 @@ const List builtInCommands = [ enum PanelType { local_manager, - market, + market_or_me, } diff --git a/app/lib/constants/sp_keys.dart b/app/lib/constants/sp_keys.dart index e3140a4..aac5a36 100644 --- a/app/lib/constants/sp_keys.dart +++ b/app/lib/constants/sp_keys.dart @@ -2,4 +2,7 @@ class SPKeys { static final String brightnessMode = 'BRIGHTNESS_MODE'; static final String appliedSchemeId = 'APPLIED_SCHEME_ID'; 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'; } diff --git a/app/lib/extensions/string_extension.dart b/app/lib/extensions/string_extension.dart index 6432f1b..8a9e5e3 100644 --- a/app/lib/extensions/string_extension.dart +++ b/app/lib/extensions/string_extension.dart @@ -1,3 +1,5 @@ extension StringNotNull on String? { bool get notNull => this != null && this != ''; + + bool get isNull => !notNull; } \ No newline at end of file diff --git a/app/lib/http/api.dart b/app/lib/http/api.dart new file mode 100644 index 0000000..44fcafa --- /dev/null +++ b/app/lib/http/api.dart @@ -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(Map res); + +typedef T HandleRespBuild(http.Response resp); + +getStatusCodeFunc(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 _handleRespBuild(BeanBuilder 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 _get( + String path, + BeanBuilder builder, { + Map? queryParams, + bool ignoreToken = false, + bool ignoreErrorHandle = false, + }) => + http + .get( + Uri( + scheme: Apis.apiScheme, + host: Apis.apiHost, + port: Apis.apiPort, + queryParameters: queryParams, + path: path, + ), + headers: { + HttpHeaders.contentTypeHeader: ContentType.json.value, + }..addAll( + ignoreToken ? {} : {HttpHeaders.authorizationHeader: 'Bearer ${H().sp.getString(SPKeys.accessToken)}'}), + ) + .then( + _handleRespBuild(builder), + onError: (e) { + if (ignoreErrorHandle) + throw e; + else + return _handleHttpError(e); + }, + ); + + static Future _post( + String path, + BeanBuilder builder, { + Map? 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: { + HttpHeaders.contentTypeHeader: ContentType.json.value, + }..addAll( + ignoreToken ? {} : {HttpHeaders.authorizationHeader: 'Bearer ${H().sp.getString(SPKeys.accessToken)}'}), + ) + .then( + _handleRespBuild(builder), + onError: (e) { + if (ignoreErrorHandle) + throw e; + else + return _handleHttpError(e); + }, + ); + + static Future loginOrSignup({ + required String email, + required String password, + }) => + _post( + Apis.auth.loginOrSignup, + LoginSuccessSerializer.fromMap, + body: { + UserFields.email: email, + UserFields.password: password, + }, + ignoreToken: true, + ); + + static Future checkAppVersion({ignoreErrorHandle = false}) => _get( + Apis.system.appVersion, + AppVersionSerializer.fromMap, + ignoreToken: true, + ignoreErrorHandle: ignoreErrorHandle, + ); +} diff --git a/app/lib/models/configs.dart b/app/lib/models/configs.dart index 691756b..4b27e0e 100644 --- a/app/lib/models/configs.dart +++ b/app/lib/models/configs.dart @@ -1,5 +1,6 @@ import 'package:dde_gesture_manager/builder/provider_annotation.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'; enum BrightnessMode { @@ -14,11 +15,49 @@ class Configs { BrightnessMode? brightnessMode; @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() { this.brightnessMode = BrightnessMode.values[H().sp.getInt(SPKeys.brightnessMode)?.clamp(0, BrightnessMode.values.length - 1) ?? 0]; this.appliedSchemeId = H().sp.getString(SPKeys.appliedSchemeId); + this.accessToken = H().sp.getString(SPKeys.accessToken); + this.email = H().sp.getString(SPKeys.loginEmail); } } diff --git a/app/lib/models/content_layout.dart b/app/lib/models/content_layout.dart index 4e11242..fed062f 100644 --- a/app/lib/models/content_layout.dart +++ b/app/lib/models/content_layout.dart @@ -6,5 +6,10 @@ class ContentLayout { bool? localManagerOpened; @ProviderModelProp() - bool? marketOpened; + bool? marketOrMeOpened; + + @ProviderModelProp() + bool? currentIsMarket = true; + + bool get isMarket => currentIsMarket ?? true; } diff --git a/app/lib/pages/content.dart b/app/lib/pages/content.dart index a674c94..5068b75 100644 --- a/app/lib/pages/content.dart +++ b/app/lib/pages/content.dart @@ -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/pages/gesture_editor.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:flutter/material.dart'; @@ -31,7 +31,7 @@ class _ContentState extends State { ChangeNotifierProvider( create: (context) => ContentLayoutProvider() ..localManagerOpened = preferredPanelsStatus.localManagerPanelOpened - ..marketOpened = preferredPanelsStatus.marketPanelOpened, + ..marketOrMeOpened = preferredPanelsStatus.marketOrMePanelOpened, ), ChangeNotifierProvider( create: (context) => CopiedGesturePropProvider.empty(), @@ -42,7 +42,7 @@ class _ContentState extends State { Future.microtask( () => context.read().setProps( localManagerOpened: preferredPanelsStatus.localManagerPanelOpened, - marketOpened: preferredPanelsStatus.marketPanelOpened, + marketOrMeOpened: preferredPanelsStatus.marketOrMePanelOpened, ), ); } @@ -52,7 +52,7 @@ class _ContentState extends State { children: [ LocalManager(), GestureEditor(), - Market(), + MarketOrMe(), ], ); }, diff --git a/app/lib/pages/gesture_editor.dart b/app/lib/pages/gesture_editor.dart index aeb17c9..537b42d 100644 --- a/app/lib/pages/gesture_editor.dart +++ b/app/lib/pages/gesture_editor.dart @@ -68,12 +68,12 @@ class GestureEditor extends StatelessWidget { ), ).tr(), Visibility( - visible: layoutProvider.marketOpened == false, + visible: layoutProvider.marketOrMeOpened == false, child: DButton( width: defaultButtonHeight, - onTap: () => H.openPanel(context, PanelType.market), + onTap: () => H.openPanel(context, PanelType.market_or_me), child: Icon( - CupertinoIcons.cart, + layoutProvider.isMarket ? CupertinoIcons.cart : CupertinoIcons.person, ), ), ), diff --git a/app/lib/pages/local_manager.dart b/app/lib/pages/local_manager.dart index 72d2252..14276bd 100644 --- a/app/lib/pages/local_manager.dart +++ b/app/lib/pages/local_manager.dart @@ -1,6 +1,5 @@ import 'package:collection/collection.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/models/configs.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.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:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -247,7 +245,6 @@ class _LocalManagerState extends State { var appliedId = localSchemes.firstWhere((ele) => ele.path == _selectedItemPath).scheme.id!; appliedId.sout(); - H().sp.updateString(SPKeys.appliedSchemeId, appliedId); context.read().setProps(appliedSchemeId: appliedId); }, ), diff --git a/app/lib/pages/market.dart b/app/lib/pages/market.dart deleted file mode 100644 index ef448c4..0000000 --- a/app/lib/pages/market.dart +++ /dev/null @@ -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().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().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), - ], - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/pages/market_or_me.dart b/app/lib/pages/market_or_me.dart new file mode 100644 index 0000000..e769260 --- /dev/null +++ b/app/lib/pages/market_or_me.dart @@ -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(); + bool isOpen = layoutProvider.marketOrMeOpened == true; + bool isMarket = layoutProvider.isMarket; + bool showLogin = context.watch().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().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().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().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().email ?? '', + style: TextStyle( + fontSize: 18, + ), + maxLines: 1, + ), + ), + DButton.logout( + enabled: true, + onTap: () => context.read().setProps(accessToken: '', email: ''), + ), + ], + ), + ], + ), + ); + } + + Widget buildMarketContent(BuildContext context) { + return Container(); + } +} diff --git a/app/lib/utils/helper.dart b/app/lib/utils/helper.dart index 61350dd..88c9b19 100644 --- a/app/lib/utils/helper.dart +++ b/app/lib/utils/helper.dart @@ -29,34 +29,42 @@ class H { initSharedPreference() async { _sp = await SharedPreferences.getInstance(); } + + late BuildContext _topContext; + + BuildContext get topContext => _topContext; + + initTopContext(BuildContext context) { + _topContext = context; + } static void openPanel(BuildContext context, PanelType panelType) { var windowWidth = MediaQuery.of(context).size.width; - if (windowWidth < minWindowSize.width + localManagerPanelWidth + marketPanelWidth) { + if (windowWidth < minWindowSize.width + localManagerPanelWidth + marketOrMePanelWidth) { context.read().setProps( localManagerOpened: panelType == PanelType.local_manager, - marketOpened: panelType == PanelType.market, + marketOrMeOpened: panelType == PanelType.market_or_me, ); } else { switch (panelType) { case PanelType.local_manager: return context.read().setProps(localManagerOpened: true); - case PanelType.market: - return context.read().setProps(marketOpened: true); + case PanelType.market_or_me: + return context.read().setProps(marketOrMeOpened: true); } } } static PreferredPanelsStatus getPreferredPanelsStatus(double windowWidth) { - var preferredPanelsStatus = PreferredPanelsStatus(localManagerPanelOpened: true, marketPanelOpened: true); - if (windowWidth > minWindowSize.width + localManagerPanelWidth + marketPanelWidth) + var preferredPanelsStatus = PreferredPanelsStatus(localManagerPanelOpened: true, marketOrMePanelOpened: true); + if (windowWidth > minWindowSize.width + localManagerPanelWidth + marketOrMePanelWidth) return preferredPanelsStatus; else if (windowWidth < minWindowSize.width + localManagerPanelWidth) return preferredPanelsStatus - ..marketPanelOpened = false + ..marketOrMePanelOpened = false ..localManagerPanelOpened = false; else - return preferredPanelsStatus..marketPanelOpened = false; + return preferredPanelsStatus..marketOrMePanelOpened = false; } static Gesture getGestureByName(String gestureName) => Gesture.values.findByName(gestureName) ?? Gesture.swipe; @@ -108,15 +116,15 @@ class H { class PreferredPanelsStatus { bool localManagerPanelOpened; - bool marketPanelOpened; + bool marketOrMePanelOpened; PreferredPanelsStatus({ required this.localManagerPanelOpened, - required this.marketPanelOpened, + required this.marketOrMePanelOpened, }); @override String toString() { - return 'PreferredPanelsStatus{localManagerPanelOpened: $localManagerPanelOpened, marketPanelOpened: $marketPanelOpened}'; + return 'PreferredPanelsStatus{localManagerPanelOpened: $localManagerPanelOpened, marketOrMePanelOpened: $marketOrMePanelOpened}'; } } diff --git a/app/lib/utils/init_linux.dart b/app/lib/utils/init_linux.dart index 7a553fe..d650cce 100644 --- a/app/lib/utils/init_linux.dart +++ b/app/lib/utils/init_linux.dart @@ -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/extensions.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/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_platform_alert/flutter_platform_alert.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'; +bool _updateChecked = false; + Future initEvents(BuildContext context) async { + H().initTopContext(context); var isDark = MediaQuery.of(context).platformBrightness == Brightness.dark; if (isDark) { context.read().setProps(isDarkMode: isDark); @@ -39,6 +48,32 @@ Future 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 initConfigs() async { diff --git a/app/lib/utils/init_web.dart b/app/lib/utils/init_web.dart index 8fe0d79..fd3335c 100644 --- a/app/lib/utils/init_web.dart +++ b/app/lib/utils/init_web.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; Future initEvents(BuildContext context) async { + H().initTopContext(context); var isDark = MediaQuery.of(context).platformBrightness == Brightness.dark; context.read().setProps(isDarkMode: isDark); } diff --git a/app/lib/utils/notificator.dart b/app/lib/utils/notificator.dart index 3ef808d..4872c46 100644 --- a/app/lib/utils/notificator.dart +++ b/app/lib/utils/notificator.dart @@ -1,5 +1,6 @@ import 'package:cherry_toast/cherry_toast.dart'; import 'package:cherry_toast/resources/arrays.dart'; +import 'package:dde_gesture_manager/extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_alert/flutter_platform_alert.dart'; @@ -27,8 +28,8 @@ class Notificator { return AlertImpl().showConfirm( windowTitle: title, text: description, - positiveButtonTitle: positiveButtonTitle, - negativeButtonTitle: negativeButtonTitle, + positiveButtonTitle: positiveButtonTitle ?? LocaleKeys.str_yes.tr(), + negativeButtonTitle: negativeButtonTitle ?? LocaleKeys.str_no.tr(), ); } diff --git a/app/lib/widgets/dde_button.dart b/app/lib/widgets/dde_button.dart index 7aad5ad..1b94fb9 100644 --- a/app/lib/widgets/dde_button.dart +++ b/app/lib/widgets/dde_button.dart @@ -107,6 +107,23 @@ class DButton extends StatefulWidget { 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({ Key? key, width = 60.0, diff --git a/app/lib/widgets/dde_markdown_field.dart b/app/lib/widgets/dde_markdown_field.dart index 46f6c7a..cdf6b8d 100644 --- a/app/lib/widgets/dde_markdown_field.dart +++ b/app/lib/widgets/dde_markdown_field.dart @@ -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/extensions.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:markdown_editor_ot/markdown_editor.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -77,11 +78,18 @@ class _DMarkdownFieldState extends State { }); }, child: MouseRegion( - cursor: widget.readOnly ? SystemMouseCursors.basic : SystemMouseCursors.click, + cursor: widget.readOnly ? SystemMouseCursors.basic : SystemMouseCursors.text, child: MdPreview( text: _previewText ?? '', padding: EdgeInsets.only(left: 15), onTapLink: _launchURL, + onCodeCopied: () { + Notificator.success( + context, + title: LocaleKeys.info_code_copied_titte.tr(), + description: LocaleKeys.info_code_copied_description.tr(), + ); + }, widgetImage: (imageUrl) => CachedNetworkImage( imageUrl: imageUrl, placeholder: (context, url) => const SizedBox( diff --git a/app/lib/widgets/login.dart b/app/lib/widgets/login.dart new file mode 100644 index 0000000..20e1386 --- /dev/null +++ b/app/lib/widgets/login.dart @@ -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 { + ValueKey _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().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().accessToken != token) + context + .read() + .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().currentActiveColor, + footerBackgroundColor: Colors.transparent, + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/version_checker.dart b/app/lib/widgets/version_checker.dart index 7e39321..f9a0a3f 100644 --- a/app/lib/widgets/version_checker.dart +++ b/app/lib/widgets/version_checker.dart @@ -1,11 +1,12 @@ -import 'dart:convert'; - 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_platform_alert/flutter_platform_alert.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:http/http.dart' as http; -import 'package:dde_gesture_manager_api/apis.dart'; -import 'package:dde_gesture_manager_api/models.dart'; +import 'package:url_launcher/url_launcher.dart'; class VersionChecker extends StatelessWidget { const VersionChecker({Key? key}) : super(key: key); @@ -22,20 +23,31 @@ class VersionChecker extends StatelessWidget { Text( '${LocaleKeys.version_current.tr()} : ${snapshot.data?.version ?? ''}', ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 3), - child: TextButton( - child: Text(LocaleKeys.version_check_update).tr(), - onPressed: () { - http.get(Uri.parse('http://127.0.0.1:3000' + Apis.system.appVersion)).then((value) { - var appVersion = AppVersionSerializer.fromMap(json.decode(value.body)); - appVersion.versionName.sout(); - appVersion.versionCode.sout(); - appVersion.sout(); - }); - }, + if (!kIsWeb) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: TextButton( + child: Text(LocaleKeys.version_check_update).tr(), + onPressed: () { + Api.checkAppVersion().then((value) { + if (value != null && (value.versionCode ?? 0) > int.parse(snapshot.data?.buildNumber ?? '0')) { + Notificator.showConfirm( + 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()); + } + }); + }, + ), ), - ), ], ), ); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 11a3699..b774f56 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -40,6 +40,8 @@ dependencies: flutter_platform_alert: ^0.2.1 cached_network_image: ^3.2.0 url_launcher: ^6.0.17 + flutter_login: ^3.1.0 + auto_size_text: ^3.0.0 markdown_editor_ot: path: 3rd_party/markdown_editor_ot cherry_toast: diff --git a/app/resources/langs/en.json b/app/resources/langs/en.json index 105a844..bcd5583 100644 --- a/app/resources/langs/en.json +++ b/app/resources/langs/en.json @@ -70,12 +70,15 @@ "delete": "delete", "duplicate": "duplicate", "apply": "apply", - "paste": "paste" + "paste": "paste", + "logout": "sign out" }, "str": { "null": "Null", "new_scheme": "New gesture scheme", - "copy": "copy" + "copy": "copy", + "yes": "Yes", + "no": "No" }, "built_in_commands": { "ShowWorkspace": "ShowWorkspace", @@ -101,6 +104,37 @@ "scheme_name_conflict": { "title": "Save failed!", "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" } } } \ No newline at end of file diff --git a/app/resources/langs/zh-CN.json b/app/resources/langs/zh-CN.json index f4be855..3832629 100644 --- a/app/resources/langs/zh-CN.json +++ b/app/resources/langs/zh-CN.json @@ -70,12 +70,15 @@ "delete": "删除", "duplicate": "复制", "apply": "应用", - "paste": "粘贴" + "paste": "粘贴", + "logout": "退出登录" }, "str": { "null": "无", "new_scheme": "新建手势方案", - "copy": "副本" + "copy": "副本", + "yes": "是", + "no": "否" }, "built_in_commands": { "ShowWorkspace": "显示工作区", @@ -101,6 +104,37 @@ "scheme_name_conflict": { "title": "保存失败!", "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": "请输入正确的邮箱" } } } \ No newline at end of file diff --git a/app/source_gen.sh b/app/source_gen.sh index 4ec7e73..9c60bc8 100755 --- a/app/source_gen.sh +++ b/app/source_gen.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash 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 -f keys -o locale_keys.g.dart \ No newline at end of file +flutter pub run easy_localization:generate -f keys -o locale_keys.g.dart +flutter packages pub run build_runner build --delete-conflicting-outputs