diff --git a/api/Dockerfile b/api/Dockerfile index c84bc87..2d8d4cb 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,14 +1,18 @@ -FROM google/dart:latest - +FROM dart:stable AS build-env +LABEL stage=dart_builder +ENV PUB_HOSTED_URL="https://pub.flutter-io.cn" +ENV ANGEL_ENV=production COPY ./ ./ - -# Install dependencies, pre-build RUN pub get +RUN dart compile exe bin/prod.dart -o /server +ENTRYPOINT ["dart", "bin/migrate.dart", "up"] -# Optionally build generaed sources. -# RUN pub run build_runner build - -# Set environment, start server +FROM scratch +WORKDIR /app ENV ANGEL_ENV=production +ADD ./views ./views +ADD ./config ./config +COPY --from=build-env /runtime/ / +COPY --from=build-env /server /app EXPOSE 3000 -CMD dart bin/prod.dart +ENTRYPOINT ["./server", "-a", "0.0.0.0", "--port", "3000"] \ No newline at end of file diff --git a/api/bin/migrate.dart b/api/bin/migrate.dart index 8963f56..44584a5 100644 --- a/api/bin/migrate.dart +++ b/api/bin/migrate.dart @@ -1,10 +1,12 @@ -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'; +import 'package:angel3_migration/angel3_migration.dart'; import 'package:angel3_migration_runner/angel3_migration_runner.dart'; import 'package:angel3_migration_runner/postgres.dart'; +import 'package:angel3_orm_postgres/angel3_orm_postgres.dart'; +import 'package:dde_gesture_manager_api/models.dart'; +import 'package:dde_gesture_manager_api/src/config/plugins/orm.dart'; +import 'package:dde_gesture_manager_api/src/models/download_history.dart'; +import 'package:dde_gesture_manager_api/src/models/like_record.dart'; import 'package:file/local.dart'; import 'package:logging/logging.dart'; @@ -28,6 +30,9 @@ void main(List args) async { var migrationRunner = PostgresMigrationRunner(connection, migrations: [ UserMigration(), UserSeed(), + SchemeMigration(), + DownloadHistoryMigration(), + LikeRecordMigration(), ]); await runMigrations(migrationRunner, args); } diff --git a/api/config/default.yaml b/api/config/default.yaml index 8ee46d8..f527d63 100644 --- a/api/config/default.yaml +++ b/api/config/default.yaml @@ -2,12 +2,17 @@ host: 127.0.0.1 port: 3000 postgres: - host: localhost + host: db port: 5432 - database_name: appdb - username: appuser - password: App1970# - useSSL: false - time_zone: UTC + database_name: gesture_manager + username: postgres + password: gesture_manager_secret + use_ssl: false + time_zone: Asia/Shanghai + +redis: + host: kv + port: 6379 + jwt_secret: "OvA9SBLnncot8gFHvt8Gh1qkQ1ptGIQW" -password_salt: "test" \ No newline at end of file +password_salt: "Z5b84rrgsKmfNFNRExAC4BCJe5aZPdJq" \ No newline at end of file diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 0000000..0938656 --- /dev/null +++ b/api/docker-compose.yml @@ -0,0 +1,45 @@ +version: "2.4" + +services: + kv: + image: redis:alpine + restart: always + container_name: dgm_redis + ports: + - "6379:6379" + networks: + - dgm_api_default + + db: + image: postgres:alpine + restart: always + container_name: dgm_postgres + environment: + POSTGRES_DB: gesture_manager + POSTGRES_PASSWORD: gesture_manager_secret + volumes: + - ../db_data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - dgm_api_default + + api: + build: . + image: dgm_api_image + container_name: dgm_api + ports: + - 3000:3000 + restart: always + depends_on: + - db + - kv + links: + - db:db + - kv:kv + networks: + - dgm_api_default + +networks: + dgm_api_default: + name: dgm_api_default diff --git a/api/lib/apis.dart b/api/lib/apis.dart index 3f1ba2f..292ce4c 100644 --- a/api/lib/apis.dart +++ b/api/lib/apis.dart @@ -7,6 +7,7 @@ class Apis { static final system = SystemApis(); static final auth = AuthApis(); + static final scheme = SchemeApis(); } class AuthApis { @@ -15,6 +16,8 @@ class AuthApis { String get loginOrSignup => [path, 'login_or_signup'].joinPath(); String confirmSignup({required StringParam accessKey}) => [path, 'confirm_sign_up', accessKey].joinPath(); + + String get status => [path, 'status'].joinPath(); } class SystemApis { @@ -23,7 +26,26 @@ class SystemApis { String get appVersion => [path, 'app-version'].joinPath(); } +class SchemeApis { + static final String path = '/scheme'; + + String get upload => [path, 'upload'].joinPath(); + + String markAsShared({required StringParam schemeId}) => [path, 'mark_as_shared', schemeId].joinPath(); + + String user({required StringParam type}) => [path, 'user', type].joinPath(); + + String market({required StringParam type, required IntParam page, required IntParam pageSize}) => [path, 'market', type, page, pageSize].joinPath(); + + String download({required StringParam schemeId}) => [path, 'download', schemeId].joinPath(); + + String like({required StringParam schemeId, required StringParam isLike}) => [path, 'like', schemeId, isLike].joinPath(); + + String get userLikes => [path, 'user-likes'].joinPath(); +} + final _paramsMap = { + 'BoolParam': BoolParam.nameOnRoute, 'IntParam': IntParam.nameOnRoute, 'DoubleParam': DoubleParam.nameOnRoute, 'StringParam': StringParam.nameOnRoute, @@ -47,6 +69,18 @@ extension RouteUrl on Function { } } +class BoolParam { + final bool val; + String? name; + + BoolParam(this.val); + + BoolParam.nameOnRoute(this.name) : val = true; + + @override + String toString() => name == null ? val.toString() : 'bool:$name'; +} + class IntParam { final int val; String? name; @@ -83,6 +117,10 @@ class StringParam { String toString() => name == null ? val.toString() : ':$name'; } +extension BoolParamExt on bool { + BoolParam get param => BoolParam(this); +} + extension IntParamExt on int { IntParam get param => IntParam(this); } diff --git a/api/lib/models.dart b/api/lib/models.dart index c608c7a..04f6864 100644 --- a/api/lib/models.dart +++ b/api/lib/models.dart @@ -1,3 +1,4 @@ export 'src/models/user.dart'; export 'src/models/app_version.dart'; -export 'src/models/login_success.dart'; \ No newline at end of file +export 'src/models/login_success.dart'; +export 'src/models/scheme.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 index 8cf8700..e209066 100644 --- a/api/lib/src/config/plugins/jwt.dart +++ b/api/lib/src/config/plugins/jwt.dart @@ -9,7 +9,7 @@ Future configureServer(Angel app) async { allowCookie: false, deserializer: (p) async => (UserQuery()..where!.id.equals(int.parse(p))) .getOne(app.container!.make()) - .then((value) => value.value), + .then((value) => value.isNotEmpty ? value.value : User(email: '')), serializer: (p) => p.id ?? '', ); await auth.configureServer(app); diff --git a/api/lib/src/models/download_history.dart b/api/lib/src/models/download_history.dart new file mode 100644 index 0000000..10218d6 --- /dev/null +++ b/api/lib/src/models/download_history.dart @@ -0,0 +1,19 @@ +import 'package:angel3_orm/angel3_orm.dart'; +import 'package:angel3_serialize/angel3_serialize.dart'; +import 'package:dde_gesture_manager_api/src/models/base_model.dart'; +import 'package:angel3_migration/angel3_migration.dart'; +import 'package:optional/optional.dart'; + +part 'download_history.g.dart'; + +@serializable +@orm +abstract class _DownloadHistory extends BaseModel { + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(isNullable: false) + int? get uid; + + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(isNullable: false) + int? get schemeId; +} \ No newline at end of file diff --git a/api/lib/src/models/like_record.dart b/api/lib/src/models/like_record.dart new file mode 100644 index 0000000..3847c6d --- /dev/null +++ b/api/lib/src/models/like_record.dart @@ -0,0 +1,40 @@ +import 'package:angel3_orm/angel3_orm.dart'; +import 'package:angel3_serialize/angel3_serialize.dart'; +import 'package:dde_gesture_manager_api/src/models/base_model.dart'; +import 'package:angel3_migration/angel3_migration.dart'; +import 'package:optional/optional.dart'; + +part 'like_record.g.dart'; + +@serializable +@orm +abstract class _LikeRecord extends BaseModel { + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(isNullable: false) + int? get uid; + + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(isNullable: false) + int? get schemeId; + + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(isNullable: false) + bool? get liked; +} + + +@serializable +@Orm(tableName: 'like_records', generateMigrations: false) +abstract class _UserLikes { + @Column(isNullable: false) + @SerializableField(isNullable: false) + int? id; + + @Column(isNullable: false) + @SerializableField(exclude: true) + int? get uid; + + @Column(isNullable: false) + @SerializableField(exclude: true) + bool? get liked; +} \ No newline at end of file diff --git a/api/lib/src/models/scheme.dart b/api/lib/src/models/scheme.dart new file mode 100644 index 0000000..c0c1cba --- /dev/null +++ b/api/lib/src/models/scheme.dart @@ -0,0 +1,181 @@ +import 'package:angel3_orm/angel3_orm.dart'; +import 'package:angel3_serialize/angel3_serialize.dart'; +import 'package:dde_gesture_manager_api/src/models/base_model.dart'; +import 'package:angel3_migration/angel3_migration.dart'; +import 'package:optional/optional.dart'; + +part 'scheme.g.dart'; + +@serializable +@orm +abstract class _Scheme extends BaseModel { + @Column(isNullable: false, indexType: IndexType.unique) + @SerializableField(isNullable: false) + String? get uuid; + + @Column(isNullable: false) + @SerializableField(isNullable: false) + String? get name; + + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(isNullable: true, exclude: true) + int? uid; + + @Column(type: ColumnType.text) + String? description; + + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(defaultValue: false, isNullable: false) + bool? get shared; + + @Column(type: ColumnType.jsonb) + @SerializableField() + @DefaultsTo([]) + List? get gestures; +} + +@serializable +@Orm(tableName: 'schemes', generateMigrations: false) +abstract class _SimpleScheme { + @Column() + int? id; + + @Column(isNullable: false, indexType: IndexType.unique) + @SerializableField(isNullable: false) + String? get uuid; + + @Column(isNullable: false) + @SerializableField(isNullable: false) + String? get name; + + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(isNullable: true, exclude: true) + int? uid; + + @Column(type: ColumnType.text) + String? description; + + @Column(isNullable: false, indexType: IndexType.standardIndex) + @SerializableField(defaultValue: false, isNullable: false) + bool? get shared; + + @SerializableField(isNullable: true) + @Column(type: ColumnType.json) + Map? get metadata; + + @SerializableField(isNullable: true) + @Column(expression: 'lr.liked') + bool? get liked; +} + +@serializable +abstract class _SimpleSchemeTransMetaData { + @SerializableField(isNullable: false) + int? id; + + @SerializableField(isNullable: false) + String? get uuid; + + @SerializableField(isNullable: false) + String? get name; + + @SerializableField(isNullable: false) + String? description; + + @SerializableField(defaultValue: false, isNullable: false) + bool? get shared; + + int? get downloads; + + int? get likes; + + bool? get liked; +} + +SimpleSchemeTransMetaData transSimpleSchemeMetaData(SimpleScheme scheme) => SimpleSchemeTransMetaData( + id: scheme.id, + description: scheme.description, + uuid: scheme.uuid, + name: scheme.name, + shared: scheme.shared, + liked: scheme.liked, + likes: scheme.metadata?['likes'] ?? 0, + downloads: scheme.metadata?['downloads'] ?? 0, + ); + +@serializable +abstract class _SchemeForDownload { + @SerializableField(isNullable: false) + String? get uuid; + + @SerializableField(isNullable: false) + String? get name; + + @Column(type: ColumnType.text) + String? description; + + @SerializableField() + @DefaultsTo([]) + List? get gestures; +} + +SchemeForDownload transSchemeForDownload(Scheme scheme) => SchemeForDownload( + uuid: scheme.uuid, + name: scheme.name, + description: scheme.description, + gestures: scheme.gestures, + ); + +@serializable +@Orm(tableName: 'schemes', generateMigrations: false) +abstract class _MarketScheme { + @Column() + int? id; + + @Column(isNullable: false, indexType: IndexType.unique) + @SerializableField(isNullable: false) + String? get uuid; + + @Column(isNullable: false) + @SerializableField(isNullable: false) + String? get name; + + @Column(type: ColumnType.text) + String? description; + + @Column(isNullable: false) + @SerializableField(exclude: true) + bool? get shared; + + @SerializableField(isNullable: true) + @Column(type: ColumnType.json) + Map? get metadata; +} + +@serializable +abstract class _MarketSchemeTransMetaData { + @SerializableField(isNullable: false) + int? id; + + @SerializableField(isNullable: false) + String? get uuid; + + @SerializableField(isNullable: false) + String? get name; + + @SerializableField(isNullable: false) + String? description; + + int? get downloads; + + int? get likes; +} + +MarketSchemeTransMetaData transMarketSchemeMetaData(MarketScheme scheme) => MarketSchemeTransMetaData( + id: scheme.id, + description: scheme.description, + uuid: scheme.uuid, + name: scheme.name, + likes: scheme.metadata?['likes'] ?? 0, + downloads: scheme.metadata?['downloads'] ?? 0, +); \ No newline at end of file diff --git a/api/lib/src/models/user.dart b/api/lib/src/models/user.dart index c34c7ef..8aab835 100644 --- a/api/lib/src/models/user.dart +++ b/api/lib/src/models/user.dart @@ -20,5 +20,9 @@ abstract class _User extends BaseModel { @SerializableField(isNullable: true, exclude: true) String? get password; + @Column(isNullable: false) + @SerializableField(defaultValue: false) + bool? get blocked; + 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 index aae33e1..41d4539 100644 --- a/api/lib/src/routes/controllers/auth_controllers.dart +++ b/api/lib/src/routes/controllers/auth_controllers.dart @@ -5,6 +5,7 @@ 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:dde_gesture_manager_api/src/routes/controllers/middlewares.dart'; import 'package:mailer/mailer.dart'; import 'package:mailer/smtp_server.dart'; import 'package:uuid/uuid.dart'; @@ -48,6 +49,8 @@ Future configureServer(Angel app) async { return res.notFound(); } else if (user.value.password != userParams.password) { return res.unauthorized(); + } else if (user.value.blocked == true) { + return res.forbidden(); } else { var angelAuth = req.container!.make(); await angelAuth.loginById(user.value.id!, req, res); @@ -75,4 +78,14 @@ Future configureServer(Angel app) async { } return res.render('sign_up_result.html', {'success': false}); }); + + app.get( + Apis.auth.status, + chain( + [ + jwtMiddleware(), + (req, res) => req.user!.blocked == false ? res.noContent() : res.forbidden(), + ], + ), + ); } diff --git a/api/lib/src/routes/controllers/controller_extensions.dart b/api/lib/src/routes/controllers/controller_extensions.dart index f13140e..117703d 100644 --- a/api/lib/src/routes/controllers/controller_extensions.dart +++ b/api/lib/src/routes/controllers/controller_extensions.dart @@ -2,6 +2,7 @@ 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/models.dart'; import 'package:dde_gesture_manager_api/src/config/plugins/redis_cache.dart'; import 'package:neat_cache/neat_cache.dart'; @@ -20,6 +21,16 @@ extension ResponseNoContent on ResponseContext { statusCode = HttpStatus.unauthorized; return close(); } + + forbidden() { + statusCode = HttpStatus.forbidden; + return close(); + } + + unProcessableEntity() { + statusCode = HttpStatus.unprocessableEntity; + return close(); + } } extension QueryWhereId on orm.Query { @@ -32,6 +43,16 @@ extension QueryExecutor on RequestContext { orm.QueryExecutor get queryExecutor => container!.make(); } -extension RedisExecutor on RequestContext { +extension RedisClient on RequestContext { Cache get cache => container!.make().cache; } + +extension JWTUserInstance on RequestContext { + User? get user { + try { + return container!.make(); + } catch (_) { + return null; + } + } +} diff --git a/api/lib/src/routes/controllers/middlewares.dart b/api/lib/src/routes/controllers/middlewares.dart index 84c9b63..f565a6e 100644 --- a/api/lib/src/routes/controllers/middlewares.dart +++ b/api/lib/src/routes/controllers/middlewares.dart @@ -2,16 +2,16 @@ import 'package:angel3_auth/angel3_auth.dart'; import 'package:angel3_framework/angel3_framework.dart'; import 'package:dde_gesture_manager_api/models.dart'; +import '../controllers/controller_extensions.dart'; -RequestHandler jwtMiddleware() { +RequestHandler jwtMiddleware({ignoreError = false}) { return (RequestContext req, ResponseContext res, {bool throwError = true}) async { - bool _reject(ResponseContext res) { + bool _reject(ResponseContext res, [ignoreError = false]) { + if (ignoreError) return true; if (throwError) { - res.statusCode = 403; - throw AngelHttpException.forbidden(); - } else { - return false; + res.forbidden(); } + return false; } if (req.container != null) { @@ -19,17 +19,22 @@ RequestHandler jwtMiddleware() { 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); + try { + 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, ignoreError); + } + } catch (e) { + if (ignoreError) return true; + rethrow; } return true; } else { - return _reject(res); + return _reject(res, ignoreError); } } else { - return _reject(res); + return _reject(res, ignoreError); } }; } diff --git a/api/lib/src/routes/controllers/scheme_controllers.dart b/api/lib/src/routes/controllers/scheme_controllers.dart new file mode 100644 index 0000000..5b68c61 --- /dev/null +++ b/api/lib/src/routes/controllers/scheme_controllers.dart @@ -0,0 +1,250 @@ +import 'dart:async'; + +import 'package:angel3_framework/angel3_framework.dart'; +import 'package:dde_gesture_manager_api/apis.dart'; +import 'package:dde_gesture_manager_api/src/models/download_history.dart'; +import 'package:dde_gesture_manager_api/src/models/like_record.dart'; +import 'package:dde_gesture_manager_api/src/models/scheme.dart'; +import 'package:dde_gesture_manager_api/src/routes/controllers/middlewares.dart'; +import 'package:logging/logging.dart'; +import 'controller_extensions.dart'; + +Future configureServer(Angel app) async { + final _log = Logger('scheme_controller'); + + app.post( + Apis.scheme.upload, + chain( + [ + jwtMiddleware(), + (req, res) async { + try { + var scheme = SchemeSerializer.fromMap(req.bodyAsMap); + var schemeQuery = SchemeQuery(); + schemeQuery.where!.uuid.equals(scheme.uuid!); + req.queryExecutor.transaction((tx) async { + var one = await schemeQuery.getOne(tx); + schemeQuery = SchemeQuery(); + schemeQuery.values.copyFrom(scheme); + schemeQuery.values.uid = req.user!.idAsInt; + if (one.isEmpty) { + return await schemeQuery.insert(tx); + } else { + schemeQuery.whereId = one.value.idAsInt; + return await schemeQuery.updateOne(tx); + } + }); + } catch (e) { + _log.severe(e); + return res.unProcessableEntity(); + } + return res.noContent(); + }, + ], + ), + ); + + app.post( + Apis.scheme.markAsShared.route, + chain( + [ + jwtMiddleware(), + (req, res) async { + var schemeId = req.params['schemeId']; + var schemeQuery = SchemeQuery(); + schemeQuery.where!.uuid.equals(schemeId); + schemeQuery.values.shared = true; + await schemeQuery.updateOne(req.queryExecutor); + return res.noContent(); + }, + ], + ), + ); + + app.get( + Apis.scheme.download.route, + chain( + [ + jwtMiddleware(ignoreError: true), + (req, res) async { + var schemeQuery = SchemeQuery(); + schemeQuery.where?.uuid.equals(req.params['schemeId']); + var optionalScheme = await schemeQuery.getOne(req.queryExecutor); + if (optionalScheme.isNotEmpty) { + var scheme = optionalScheme.value; + if (req.user != null) { + /// 增加用户下载记录 + var downloadHistoryQuery = DownloadHistoryQuery(); + downloadHistoryQuery.where?.uid.equals(req.user!.idAsInt); + downloadHistoryQuery.where?.schemeId.equals(scheme.idAsInt); + var notExist = (await downloadHistoryQuery.getOne(req.queryExecutor)).isEmpty; + if (notExist) { + downloadHistoryQuery = DownloadHistoryQuery(); + downloadHistoryQuery.values.copyFrom(DownloadHistory(uid: req.user!.idAsInt, schemeId: scheme.idAsInt)); + await downloadHistoryQuery.insert(req.queryExecutor); + } + } + + /// 增加方案的下载数量 + schemeQuery = SchemeQuery(); + schemeQuery.whereId = scheme.idAsInt; + Map metadata = Map.from(scheme.metadata!); + metadata.update('downloads', (value) => ++value, ifAbsent: () => 1); + schemeQuery.values.metadata = metadata; + schemeQuery.updateOne(req.queryExecutor); + + return res.json(transSchemeForDownload(scheme)); + } + return res.notFound(); + }, + ], + ), + ); + + app.get( + Apis.scheme.like.route, + chain( + [ + jwtMiddleware(), + (req, res) async { + bool isLike = req.params['isLike'] == 'like'; + bool needUpdate = true; + var schemeQuery = SchemeQuery(); + schemeQuery.where?.uuid.equals(req.params['schemeId']); + var optionalScheme = await schemeQuery.getOne(req.queryExecutor); + if (optionalScheme.isNotEmpty) { + var scheme = optionalScheme.value; + if (req.user != null) { + /// 增加用户点赞记录 + var likeRecordQuery = LikeRecordQuery(); + likeRecordQuery.where?.uid.equals(req.user!.idAsInt); + likeRecordQuery.where?.schemeId.equals(scheme.idAsInt); + var likeRecordCheck = await likeRecordQuery.getOne(req.queryExecutor); + likeRecordQuery = LikeRecordQuery(); + likeRecordQuery.values + .copyFrom(LikeRecord(uid: req.user!.idAsInt, schemeId: scheme.idAsInt, liked: isLike)); + if (likeRecordCheck.isEmpty) { + likeRecordQuery.insert(req.queryExecutor); + } else if (likeRecordCheck.value.liked != isLike) { + likeRecordQuery.whereId = likeRecordCheck.value.idAsInt; + likeRecordQuery.updateOne(req.queryExecutor); + } else { + needUpdate = false; + } + } + + if (needUpdate) { + /// 增加/减少方案的点赞数量 + schemeQuery = SchemeQuery(); + schemeQuery.whereId = scheme.idAsInt; + Map metadata = Map.from(scheme.metadata!); + metadata.update('likes', (value) => isLike ? ++value : --value, ifAbsent: () => 1); + schemeQuery.values.metadata = metadata; + schemeQuery.updateOne(req.queryExecutor); + } + + return res.noContent(); + } + return res.notFound(); + }, + ], + ), + ); + + app.get( + Apis.scheme.user.route, + chain( + [ + jwtMiddleware(), + (req, res) async { + var schemeQuery = SimpleSchemeQuery(); + var type = req.params['type']; + var likeRecordTableName = LikeRecordQuery().tableName; + schemeQuery.leftJoin(likeRecordTableName, SchemeFields.id, LikeRecordFields.schemeId, alias: 'lr'); + + switch (type) { + case 'uploaded': + schemeQuery.where!.uid.equals(req.user!.idAsInt); + break; + case 'downloaded': + var downloadHistoryTableName = DownloadHistoryQuery().tableName; + schemeQuery.leftJoin(downloadHistoryTableName, SchemeFields.id, DownloadHistoryFields.schemeId, + alias: 'dh'); + schemeQuery.where!.raw('dh.${DownloadHistoryFields.uid} = ${req.user!.idAsInt}'); + break; + case 'liked': + schemeQuery.where!.raw('lr.${LikeRecordFields.uid} = ${req.user!.idAsInt}'); + schemeQuery.where!.raw('lr.${LikeRecordFields.liked} = true'); + break; + default: + return res.unProcessableEntity(); + } + schemeQuery.orderBy('${schemeQuery.tableName}.${SchemeFields.updatedAt}', descending: true); + return schemeQuery.get(req.queryExecutor).then((value) => value.map(transSimpleSchemeMetaData).toList()); + }, + ], + ), + ); + + const recommend = "(metadata->'recommends') is null ,(metadata->'recommends')::int"; + const updated = "updated_at"; + const likes = "(metadata->'likes') is null ,(metadata->'likes')::int"; + const downloads = "(metadata->'downloads') is null ,(metadata->'downloads')::int"; + + app.get(Apis.scheme.market.route, (req, res) async { + var schemeQuery = MarketSchemeQuery(); + var type = req.params['type']; + var page = req.params['page'] as int; + var pageSize = req.params['pageSize'] as int; + schemeQuery.where?.shared.equals(true); + late List orders; + + switch (type) { + case 'recommend': + // orders = [recommend, likes, downloads, SchemeFields.id]; + orders = [recommend]; + break; + case 'updated': + orders = [updated]; + break; + case 'likes': + // orders = [likes, recommend, downloads, SchemeFields.id]; + orders = [likes]; + break; + case 'downloads': + // orders = [downloads, recommend, likes, SchemeFields.id]; + orders = [downloads]; + break; + default: + return res.unProcessableEntity(); + } + for (var order in orders) { + schemeQuery.orderBy(order, descending: true); + } + schemeQuery + ..offset(page * pageSize) + ..limit(pageSize + 1); + return schemeQuery.get(req.queryExecutor).then((value) { + var hasMore = value.length > pageSize; + if (hasMore) value.removeLast(); + return { + 'hasMore': hasMore, + 'items': value.map(transMarketSchemeMetaData).toList(), + }; + }); + }); + + app.get( + Apis.scheme.userLikes, + chain( + [ + jwtMiddleware(), + (req, res) async => (UserLikesQuery() + ..where?.uid.equals(req.user!.idAsInt) + ..where?.liked.equals(true)) + .get(req.queryExecutor) + .then((value) => value.map((e) => e.id).toList()), + ], + ), + ); +} diff --git a/api/lib/src/routes/routes.dart b/api/lib/src/routes/routes.dart index 4b86464..83f4a8e 100644 --- a/api/lib/src/routes/routes.dart +++ b/api/lib/src/routes/routes.dart @@ -2,6 +2,7 @@ import 'package:angel3_framework/angel3_framework.dart'; import 'package:file/file.dart'; import 'controllers/auth_controllers.dart' as auth_controllers; import 'controllers/system_controllers.dart' as system_controllers; +import 'controllers/scheme_controllers.dart' as scheme_controllers; /// Put your app routes here! /// @@ -22,6 +23,7 @@ AngelConfigurer configureServer(FileSystem fileSystem) { // Typically, you want to mount controllers first, after any global middleware. await app.configure(system_controllers.configureServerWithFileSystem(fileSystem)); await app.configure(auth_controllers.configureServer); + await app.configure(scheme_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 9831fbc..2b58d05 100644 --- a/api/pubspec.yaml +++ b/api/pubspec.yaml @@ -8,13 +8,11 @@ dependencies: angel3_auth: ^4.0.0 angel3_configuration: ^4.1.0 angel3_framework: ^4.2.0 - angel3_migration: ^4.0.0 - angel3_orm: ^4.0.0 - angel3_orm_postgres: ^3.0.0 + angel3_migration: ^4.0.3 + angel3_orm: ^4.0.5 + angel3_orm_postgres: ^3.3.0 angel3_serialize: ^4.1.0 - angel3_production: ^3.1.0 - angel3_static: ^4.1.0 - angel3_validate: ^4.0.0 + angel3_production: ^3.1.2 belatuk_pretty_logging: ^4.0.0 optional: ^6.0.0 logging: ^1.0.0 @@ -24,16 +22,13 @@ dependencies: neat_cache: path: 3rd_party/neat_cache dev_dependencies: - angel3_hot: ^4.2.0 + angel3_hot: ^4.3.0 angel3_jinja: ^2.0.1 - angel3_migration_runner: ^4.0.0 - angel3_orm_generator: ^4.1.0 - angel3_serialize_generator: ^4.2.0 + angel3_migration_runner: ^4.1.1 + angel3_orm_generator: ^4.3.0 + angel3_serialize_generator: ^4.3.0 angel3_test: ^4.0.0 build_runner: ^2.0.3 io: ^1.0.0 test: ^1.17.5 lints: ^1.0.0 - - - diff --git a/api/start.sh b/api/start.sh new file mode 100644 index 0000000..f084689 --- /dev/null +++ b/api/start.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +docker-compose build +docker-compose up -d +MIGRATION_IMAGE="$(docker image ls --filter label=stage=dart_builder -q)" +docker run --name=dgm_api_migrate --network dgm_api_default "$MIGRATION_IMAGE" +docker rm "$(docker ps -a --filter name=dgm_api_migrate -q)" +docker image prune -f --filter label=stage=dart_builder \ No newline at end of file diff --git a/app/lib/constants/constants.dart b/app/lib/constants/constants.dart index 0a4ab78..f4214a8 100644 --- a/app/lib/constants/constants.dart +++ b/app/lib/constants/constants.dart @@ -3,7 +3,7 @@ import 'package:flutter/cupertino.dart'; /// [UOS设计指南](https://docs.uniontech.com/zh/content/t_dbG3kBK9iDf9B963ok) const double localManagerPanelWidth = 260; -const double marketOrMePanelWidth = 300; +const double marketOrMePanelWidth = 450; const shortDuration = const Duration(milliseconds: 100); diff --git a/app/lib/http/api.dart b/app/lib/http/api.dart index 44fcafa..8fe7a4f 100644 --- a/app/lib/http/api.dart +++ b/app/lib/http/api.dart @@ -3,8 +3,10 @@ import 'dart:io'; import 'package:dde_gesture_manager/constants/sp_keys.dart'; import 'package:dde_gesture_manager/extensions.dart'; +import 'package:dde_gesture_manager/models/scheme.dart' as AppScheme; import 'package:dde_gesture_manager/utils/helper.dart'; import 'package:dde_gesture_manager/utils/notificator.dart'; +import 'package:dde_gesture_manager/widgets/me.dart'; import 'package:dde_gesture_manager_api/apis.dart'; import 'package:dde_gesture_manager_api/models.dart'; import 'package:http/http.dart' as http; @@ -13,7 +15,12 @@ typedef T BeanBuilder(Map res); typedef T HandleRespBuild(http.Response resp); -getStatusCodeFunc(Map resp) => resp["statusCode"]; +typedef int GetStatusCodeFunc(Map resp); + +int getStatusCodeFunc(Map resp) => resp["statusCode"] as int; + +BeanBuilder> listRespBuilderWrap(BeanBuilder builder) => + (Map resp) => (resp['list'] as List).map((e) => builder(e)).toList(); class HttpErrorCode extends Error { int statusCode; @@ -39,18 +46,20 @@ class Api { } } - static HandleRespBuild _handleRespBuild(BeanBuilder builder) => (http.Response resp) { - if (builder == getStatusCodeFunc) return builder({"statusCode": resp.statusCode}); - T res; + static HandleRespBuild _handleRespBuild(BeanBuilder builder) => (http.Response resp) { + if (builder is GetStatusCodeFunc) return builder({"statusCode": resp.statusCode}); + T? res; try { - res = builder(json.decode(resp.body)); + var decodeBody = json.decode(utf8.decode(resp.bodyBytes)); + res = decodeBody is Map ? builder(decodeBody) : builder({'list': decodeBody}); } catch (e) { + e.sout(); throw HttpErrorCode(resp.statusCode, message: resp.body); } return res; }; - static Future _get( + static Future _get( String path, BeanBuilder builder, { Map? queryParams, @@ -67,21 +76,21 @@ class Api { path: path, ), headers: { - HttpHeaders.contentTypeHeader: ContentType.json.value, + HttpHeaders.contentTypeHeader: ContentType.json.toString(), }..addAll( ignoreToken ? {} : {HttpHeaders.authorizationHeader: 'Bearer ${H().sp.getString(SPKeys.accessToken)}'}), ) - .then( + .then( _handleRespBuild(builder), onError: (e) { if (ignoreErrorHandle) throw e; else - return _handleHttpError(e); + _handleHttpError(e); }, ); - static Future _post( + static Future _post( String path, BeanBuilder builder, { Map? body, @@ -98,17 +107,17 @@ class Api { ), body: jsonEncode(body), headers: { - HttpHeaders.contentTypeHeader: ContentType.json.value, + HttpHeaders.contentTypeHeader: ContentType.json.toString(), }..addAll( ignoreToken ? {} : {HttpHeaders.authorizationHeader: 'Bearer ${H().sp.getString(SPKeys.accessToken)}'}), ) - .then( + .then( _handleRespBuild(builder), onError: (e) { if (ignoreErrorHandle) throw e; else - return _handleHttpError(e); + _handleHttpError(e); }, ); @@ -132,4 +141,36 @@ class Api { ignoreToken: true, ignoreErrorHandle: ignoreErrorHandle, ); + + static Future checkAuthStatus() => _get(Apis.auth.status, getStatusCodeFunc, ignoreErrorHandle: true) + .then((value) => value == HttpStatus.noContent); + + static Future uploadScheme({required AppScheme.Scheme scheme, required bool share}) => _post( + Apis.scheme.upload, + getStatusCodeFunc, + body: SchemeSerializer.toMap( + Scheme( + name: scheme.name, + uuid: scheme.id, + description: scheme.description, + gestures: scheme.gestures, + shared: share, + ), + ), + ).then((value) => value == HttpStatus.noContent); + + static Future?> userSchemes({required SchemeListType type}) => + _get(Apis.scheme.user(type: type.name.param), listRespBuilderWrap(SimpleSchemeTransMetaDataSerializer.fromMap)); + + static Future likeScheme({required String schemeId, required bool isLike}) => _get( + Apis.scheme.like(schemeId: schemeId.param, isLike: StringParam(isLike ? 'like' : 'unlike')), + getStatusCodeFunc) + .then((value) { + return value == HttpStatus.noContent; + }); + + static Future downloadScheme({required String schemeId}) => _get( + Apis.scheme.download(schemeId: schemeId.param), + SchemeForDownloadSerializer.fromMap, + ); } diff --git a/app/lib/main.dart b/app/lib/main.dart index 74cf9c3..93e1f5d 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -3,6 +3,7 @@ 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/configs.dart'; import 'package:dde_gesture_manager/models/configs.provider.dart'; import 'package:dde_gesture_manager/models/settings.provider.dart'; @@ -10,6 +11,7 @@ import 'package:dde_gesture_manager/themes/dark.dart'; import 'package:dde_gesture_manager/themes/light.dart'; import 'package:dde_gesture_manager/utils/helper.dart'; import 'package:dde_gesture_manager/utils/init.dart'; +import 'package:dde_gesture_manager/utils/simple_throttle.dart'; import 'package:flutter/material.dart'; import 'pages/home.dart'; @@ -68,7 +70,10 @@ class MyApp extends StatelessWidget { ], ), firstChild: Builder(builder: (context) { - Future.microtask(() => initEvents(context)); + Future.microtask(() { + initEvents(context); + SimpleThrottle.throttledFunc(_checkAuthStatus, timeout: const Duration(minutes: 5))?.call(context); + }); return Container(); }), secondChild: HomePage(), @@ -79,3 +84,15 @@ class MyApp extends StatelessWidget { ); } } + +void _checkAuthStatus(BuildContext context) { + if (H().lastCheckAuthStatusTime != null && + H().lastCheckAuthStatusTime!.difference(DateTime.now()) < Duration(minutes: 10)) return; + if (context.read().accessToken.notNull) { + Api.checkAuthStatus().then((value) { + if (!value) context.read().setProps(email: '', accessToken: ''); + }); + } else { + H().lastCheckAuthStatusTime = DateTime.now(); + } +} diff --git a/app/lib/models/content_layout.dart b/app/lib/models/content_layout.dart index fed062f..57325ef 100644 --- a/app/lib/models/content_layout.dart +++ b/app/lib/models/content_layout.dart @@ -1,4 +1,7 @@ import 'package:dde_gesture_manager/builder/provider_annotation.dart'; +import 'package:dde_gesture_manager/constants/sp_keys.dart'; +import 'package:dde_gesture_manager/utils/helper.dart'; +import 'package:dde_gesture_manager/extensions.dart'; @ProviderModel() class ContentLayout { @@ -9,7 +12,7 @@ class ContentLayout { bool? marketOrMeOpened; @ProviderModelProp() - bool? currentIsMarket = true; + bool? currentIsMarket = H().sp.getString(SPKeys.accessToken).isNull; bool get isMarket => currentIsMarket ?? true; } diff --git a/app/lib/models/scheme.dart b/app/lib/models/scheme.dart index 5ffea37..6d2a385 100644 --- a/app/lib/models/scheme.dart +++ b/app/lib/models/scheme.dart @@ -131,7 +131,7 @@ class Scheme { @ProviderModelProp() List? gestures; - bool get readOnly => uploaded == true || fromMarket == true || id == Uuid.NAMESPACE_NIL; + bool get readOnly => fromMarket == true || id == Uuid.NAMESPACE_NIL; Scheme.parse(scheme) { if (scheme is String) scheme = json.decode(scheme); diff --git a/app/lib/pages/gesture_editor.dart b/app/lib/pages/gesture_editor.dart index 537b42d..78a89a1 100644 --- a/app/lib/pages/gesture_editor.dart +++ b/app/lib/pages/gesture_editor.dart @@ -1,6 +1,8 @@ import 'package:adaptive_scrollbar/adaptive_scrollbar.dart'; import 'package:dde_gesture_manager/constants/constants.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/content_layout.provider.dart'; import 'package:dde_gesture_manager/models/local_schemes_provider.dart'; import 'package:dde_gesture_manager/models/scheme.dart'; @@ -18,6 +20,7 @@ import 'package:dde_gesture_manager/widgets/table_cell_shortcut_listener.dart'; import 'package:dde_gesture_manager/widgets/table_cell_text_field.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_platform_alert/flutter_platform_alert.dart'; import 'package:uuid/uuid.dart'; const double _headingRowHeight = 56; @@ -292,6 +295,52 @@ class GestureEditor extends StatelessWidget { }, ), ), + Padding( + padding: const EdgeInsets.only(left: 10), + child: DButton.upload( + enabled: schemeProvider.readOnly == false, + onTap: () async { + if (context.read().accessToken.isNull) { + return Notificator.showAlert( + title: LocaleKeys.info_login_for_upload_title.tr(), + description: LocaleKeys.info_login_for_upload_description.tr(), + ).then((value) { + if (value == CustomButton.positiveButton) { + context + .read() + .setProps(marketOrMeOpened: true, currentIsMarket: false); + } + }); + } + Notificator.showConfirm( + title: LocaleKeys.info_upload_and_share_title.tr(), + description: LocaleKeys.info_upload_and_share_description.tr(), + positiveButtonTitle: LocaleKeys.str_share.tr(), + negativeButtonTitle: LocaleKeys.str_cancel.tr(), + ).then((value) { + bool? _share; + if (value == CustomButton.positiveButton) + _share = true; + else if (value == CustomButton.negativeButton) _share = false; + + if (_share != null) { + Api.uploadScheme(scheme: schemeProvider, share: _share).then((value) { + if (value) { + Notificator.success(context, title: LocaleKeys.info_upload_success.tr()); + var localSchemesProvider = context.read(); + var localSchemeEntry = localSchemesProvider.schemes! + .firstWhere((ele) => ele.scheme.id == schemeProvider.id); + localSchemeEntry.scheme.uploaded = true; + localSchemeEntry.save(localSchemesProvider); + } else { + Notificator.error(context, title: LocaleKeys.info_upload_failed.tr()); + } + }); + } + }); + }, + ), + ), ], ), Divider(), diff --git a/app/lib/pages/local_manager.dart b/app/lib/pages/local_manager.dart index 14276bd..514a168 100644 --- a/app/lib/pages/local_manager.dart +++ b/app/lib/pages/local_manager.dart @@ -184,8 +184,8 @@ class _LocalManagerState extends State { ), ), ), - Container(height: 5), - Container( + Padding( + padding: const EdgeInsets.only(top: 5), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ diff --git a/app/lib/pages/market_or_me.dart b/app/lib/pages/market_or_me.dart index e769260..447c963 100644 --- a/app/lib/pages/market_or_me.dart +++ b/app/lib/pages/market_or_me.dart @@ -1,10 +1,11 @@ -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:dde_gesture_manager/widgets/market.dart'; +import 'package:dde_gesture_manager/widgets/me.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -18,15 +19,14 @@ class MarketOrMe extends StatelessWidget { 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, + width: isOpen ? marketOrMePanelWidth * 1 : 0, child: OverflowBox( alignment: Alignment.centerLeft, - maxWidth: marketOrMePanelWidth * (showLogin ? 1.5 : 1), - minWidth: marketOrMePanelWidth * (showLogin ? 1.5 : 1), + maxWidth: marketOrMePanelWidth, + minWidth: marketOrMePanelWidth, child: Material( color: context.t.backgroundColor, elevation: isOpen ? 10 : 0, @@ -82,36 +82,8 @@ class MarketOrMe extends StatelessWidget { 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: ''), - ), - ], - ), - ], - ), - ); + return Expanded(child: MeWidget()); } - Widget buildMarketContent(BuildContext context) { - return Container(); - } + Widget buildMarketContent(BuildContext context) => Expanded(child: MarketWidget()); } diff --git a/app/lib/utils/helper.dart b/app/lib/utils/helper.dart index 88c9b19..b4b1ad3 100644 --- a/app/lib/utils/helper.dart +++ b/app/lib/utils/helper.dart @@ -34,6 +34,8 @@ class H { BuildContext get topContext => _topContext; + DateTime? lastCheckAuthStatusTime; + initTopContext(BuildContext context) { _topContext = context; } diff --git a/app/lib/utils/init_linux.dart b/app/lib/utils/init_linux.dart index d650cce..b69cbf0 100644 --- a/app/lib/utils/init_linux.dart +++ b/app/lib/utils/init_linux.dart @@ -49,9 +49,9 @@ Future initEvents(BuildContext context) async { } } - if (!_updateChecked) + if (!_updateChecked) { + _updateChecked = true; 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; @@ -74,6 +74,7 @@ Future initEvents(BuildContext context) async { }); } }); + } } Future initConfigs() async { diff --git a/app/lib/utils/notificator.dart b/app/lib/utils/notificator.dart index 4872c46..f686dcc 100644 --- a/app/lib/utils/notificator.dart +++ b/app/lib/utils/notificator.dart @@ -15,7 +15,7 @@ class Notificator { return AlertImpl().showAlert( windowTitle: title, text: description, - positiveButtonTitle: positiveButtonTitle, + positiveButtonTitle: positiveButtonTitle ?? LocaleKeys.str_ok.tr(), ); } diff --git a/app/lib/utils/simple_throttle.dart b/app/lib/utils/simple_throttle.dart new file mode 100644 index 0000000..7e22507 --- /dev/null +++ b/app/lib/utils/simple_throttle.dart @@ -0,0 +1,45 @@ +import 'dart:collection'; + +import 'package:collection/collection.dart'; + +class _SimpleThrottleNode { + int funcHashCode; + int timestamp; + + _SimpleThrottleNode(this.funcHashCode, this.timestamp); +} + +typedef void _VoidFunc(); + +final _simpleThrottleQueue = Queue(); + +/// Usage: If you have a function : test(int n) => n; +/// you can use SimpleThrottle.throttledFunc(test)?.call(1) to make it throttled +/// this will return function's return value if last call time over the timeout +/// otherwise this will return null. +/// If your function is a 'void function()', you can use SimpleThrottle.invoke(func) to call it throttled, +/// and you can get a throttled function by SimpleThrottle.bind(func) if you do not call it immediately. +class SimpleThrottle { + static T? throttledFunc(T func, + {String? funcKey, Duration timeout = const Duration(seconds: 1)}) { + var node = _simpleThrottleQueue.firstWhereOrNull((element) => element.funcHashCode == (funcKey ?? func).hashCode); + if (node != null) { + if (DateTime.now().millisecondsSinceEpoch - node.timestamp < timeout.inMilliseconds) + return null; + else + node.timestamp = DateTime.now().millisecondsSinceEpoch; + } else { + _simpleThrottleQueue.add(_SimpleThrottleNode((funcKey ?? func).hashCode, DateTime.now().millisecondsSinceEpoch)); + while (_simpleThrottleQueue.length > 16) { + _simpleThrottleQueue.removeFirst(); + } + } + return func; + } + + static void invoke(_VoidFunc func, {String? funcKey, Duration timeout = const Duration(seconds: 1)}) => + throttledFunc(func, timeout: timeout, funcKey: funcKey)?.call(); + + static _VoidFunc bind(_VoidFunc func, {String? funcKey, Duration timeout = const Duration(seconds: 1)}) => + () => invoke(func, timeout: timeout, funcKey: funcKey); +} diff --git a/app/lib/widgets/dde_button.dart b/app/lib/widgets/dde_button.dart index 1b94fb9..f0bac0a 100644 --- a/app/lib/widgets/dde_button.dart +++ b/app/lib/widgets/dde_button.dart @@ -124,6 +124,74 @@ class DButton extends StatefulWidget { message: LocaleKeys.operation_logout.tr(), )); + factory DButton.upload({ + 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.cloud_upload, size: 20)), + message: LocaleKeys.operation_upload.tr(), + )); + + factory DButton.download({ + Key? key, + required enabled, + GestureTapCallback? onTap, + height = defaultButtonHeight * .7, + width = defaultButtonHeight * .7, + }) => + DButton( + key: key, + width: width, + height: height, + onTap: enabled ? onTap : null, + child: Tooltip( + child: Opacity(opacity: enabled ? 1 : 0.4, child: const Icon(Icons.file_download, size: 18)), + message: LocaleKeys.operation_download.tr(), + )); + + factory DButton.share({ + Key? key, + required enabled, + GestureTapCallback? onTap, + height = defaultButtonHeight * .7, + width = defaultButtonHeight * .7, + }) => + DButton( + key: key, + width: width, + height: height, + onTap: enabled ? onTap : null, + child: Tooltip( + child: Opacity(opacity: enabled ? 1 : 0.4, child: const Icon(Icons.share, size: 18)), + message: LocaleKeys.operation_share.tr(), + )); + + factory DButton.like({ + Key? key, + required enabled, + GestureTapCallback? onTap, + height = defaultButtonHeight * .7, + width = defaultButtonHeight * .7, + }) => + DButton( + key: key, + width: width, + height: height, + onTap: enabled ? onTap : null, + child: Tooltip( + child: Opacity(opacity: enabled ? 1 : 0.4, child: const Icon(Icons.thumb_up, size: 16)), + message: LocaleKeys.operation_like.tr(), + )); + factory DButton.dropdown({ Key? key, width = 60.0, diff --git a/app/lib/widgets/login.dart b/app/lib/widgets/login.dart index 20e1386..ef648ce 100644 --- a/app/lib/widgets/login.dart +++ b/app/lib/widgets/login.dart @@ -54,6 +54,12 @@ class _LoginWidgetState extends State { title: LocaleKeys.info_sign_up_hint_title.tr(), description: LocaleKeys.info_sign_up_hint_description.tr(), ); + else if (code == HttpStatus.forbidden) + Notificator.info( + context, + title: LocaleKeys.info_user_blocked_hint_title.tr(), + description: LocaleKeys.info_user_blocked_hint_description.tr(), + ); else throw e; } diff --git a/app/lib/widgets/market.dart b/app/lib/widgets/market.dart new file mode 100644 index 0000000..33886a7 --- /dev/null +++ b/app/lib/widgets/market.dart @@ -0,0 +1,102 @@ +import 'package:dde_gesture_manager/constants/constants.dart'; +import 'package:dde_gesture_manager/widgets/dde_button.dart'; +import 'package:flutter/material.dart'; +import 'package:dde_gesture_manager/extensions.dart'; + +enum MarketSortType { + recommend, + updated, + likes, + downloads, +} + +class MarketWidget extends StatefulWidget { + const MarketWidget({Key? key}) : super(key: key); + + @override + _MarketWidgetState createState() => _MarketWidgetState(); +} + +class _MarketWidgetState extends State { + MarketSortType _type = MarketSortType.recommend; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(top: 15, bottom: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MarketSortType.recommend, + MarketSortType.updated, + MarketSortType.likes, + MarketSortType.downloads, + ] + .map( + (e) => Flexible( + flex: 1, + fit: FlexFit.tight, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + setState(() { + _type = e; + }); + }, + child: Center( + child: Text( + '${LocaleKeys.market_sort_types}.${e.name}'.tr(), + style: _type == e ? TextStyle(fontWeight: FontWeight.bold, fontSize: 15) : null, + ), + ), + ), + ), + ), + ) + .toList(), + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all( + width: .3, + color: context.t.dividerColor, + ), + borderRadius: BorderRadius.circular(defaultBorderRadius), + ), + child: Column( + children: [Text('asd')], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DButton.like( + enabled: true, + onTap: () {}, + ), + DButton.download( + enabled: true, + onTap: () {}, + ), + ] + .map((e) => Padding( + padding: const EdgeInsets.only(right: 4), + child: e, + )) + .toList(), + ), + ), + ], + ); + } +} diff --git a/app/lib/widgets/me.dart b/app/lib/widgets/me.dart new file mode 100644 index 0000000..c8d00d5 --- /dev/null +++ b/app/lib/widgets/me.dart @@ -0,0 +1,293 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:dde_gesture_manager/constants/constants.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/notificator.dart'; +import 'package:dde_gesture_manager_api/models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_alert/flutter_platform_alert.dart'; +import 'package:markdown_editor_ot/markdown_editor.dart'; +import 'package:numeral/fun.dart'; + +import 'dde_button.dart'; + +enum SchemeListType { + uploaded, + downloaded, + liked, +} + +class MeWidget extends StatefulWidget { + const MeWidget({Key? key}) : super(key: key); + + @override + _MeWidgetState createState() => _MeWidgetState(); +} + +class _MeWidgetState extends State { + List _schemes = []; + SchemeListType _type = SchemeListType.uploaded; + String? _selected; + String? _hovering; + + @override + void initState() { + super.initState(); + Api.userSchemes(type: _type).then((value) { + if (mounted && value != null) + setState(() { + _schemes = value; + _selected = value.isNotEmpty ? value.first.uuid : null; + }); + }); + } + + Color _getItemBackgroundColor(int index, String? schemeId) { + Color _color = index % 2 == 0 ? context.t.scaffoldBackgroundColor : context.t.backgroundColor; + if (schemeId == _hovering) _color = context.t.dialogBackgroundColor; + if (schemeId == _selected) _color = context.read().currentActiveColor; + return _color; + } + + _refreshList() { + Future.delayed(const Duration(milliseconds: 100), () { + Api.userSchemes(type: _type).then((value) { + if (mounted && value != null) + setState(() { + _schemes = value; + }); + }); + }); + } + + @override + Widget build(BuildContext context) { + var currentSelectedScheme = _schemes.firstWhereOrNull((e) => e.uuid == _selected); + return Padding( + padding: EdgeInsets.only(top: 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: ''), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 3, bottom: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SchemeListType.uploaded, + SchemeListType.downloaded, + SchemeListType.liked, + ] + .map( + (e) => Flexible( + flex: 1, + fit: FlexFit.tight, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + setState(() { + _type = e; + }); + Api.userSchemes(type: e).then((value) { + if (mounted && value != null) + setState(() { + _schemes = value; + _selected = value.first.uuid; + }); + }); + }, + child: Center( + child: Text( + '${LocaleKeys.me_scheme_types}.${e.name}'.tr(), + style: _type == e ? TextStyle(fontWeight: FontWeight.bold, fontSize: 15) : null, + ), + ), + ), + ), + ), + ) + .toList(), + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all( + width: .3, + color: context.t.dividerColor, + ), + borderRadius: BorderRadius.circular(defaultBorderRadius), + ), + child: Column( + children: [ + Flexible( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 1, vertical: 2), + child: ListView.builder( + itemBuilder: (context, index) => GestureDetector( + onTap: () { + setState(() { + _selected = _schemes[index].uuid; + }); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) { + setState(() { + _hovering = _schemes[index].uuid; + }); + }, + child: Container( + color: _getItemBackgroundColor(index, _schemes[index].uuid), + child: Padding( + padding: const EdgeInsets.only(left: 6, right: 12.0), + child: DefaultTextStyle( + style: context.t.textTheme.bodyText2!.copyWith( + color: _selected == _schemes[index].uuid ? Colors.white : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_schemes[index].name ?? ''), + Row( + children: [ + SizedBox( + width: 50, + child: Align( + alignment: Alignment.centerRight, + child: AutoSizeText( + '${numeral(_schemes[index].downloads ?? 0, fractionDigits: 1)}' + .padLeft(5), + ), + ), + ), + Icon( + Icons.file_download, + size: 18, + ), + SizedBox( + width: 50, + child: Align( + alignment: Alignment.centerRight, + child: AutoSizeText( + '${numeral(_schemes[index].likes ?? 0, fractionDigits: 1)}'.padLeft(5), + ), + ), + ), + Icon(_schemes[index].liked == true ? Icons.thumb_up : Icons.thumb_up_off_alt, + size: 17), + ] + .map((e) => Padding( + padding: const EdgeInsets.only(right: 3), + child: e, + )) + .toList(), + ), + ], + ), + ), + ), + ), + ), + ), + itemCount: _schemes.length, + ), + ), + ), + Divider(thickness: .5), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: MdPreview( + text: _schemes.firstWhereOrNull((e) => e.uuid == _selected)?.description ?? '', + widgetImage: (imageUrl) => CachedNetworkImage( + imageUrl: imageUrl, + placeholder: (context, url) => const SizedBox( + width: double.infinity, + height: 300, + child: Center(child: CircularProgressIndicator()), + ), + errorWidget: (context, url, error) => const Icon(Icons.error), + ), + onCodeCopied: () {}, + ), + ), + ) + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (_type == SchemeListType.uploaded) + DButton.share( + enabled: currentSelectedScheme?.shared == false, + onTap: () { + Notificator.showConfirm( + title: LocaleKeys.info_share_title.tr(), + description: LocaleKeys.info_share_description.tr()) + .then((value) { + if (value == CustomButton.positiveButton) { + Notificator.success(context, title: LocaleKeys.info_share_success.tr()); + } + }); + }, + ), + DButton.like( + enabled: true, + onTap: () { + Api.likeScheme(schemeId: currentSelectedScheme!.uuid!, isLike: !currentSelectedScheme.liked!) + .then((value) { + if (value) { + _refreshList(); + } + }); + }, + ), + DButton.download( + enabled: true, + onTap: () { + Api.downloadScheme(schemeId: currentSelectedScheme!.uuid!).then((value) { + value.sout(); + _refreshList(); + }); + }, + ), + ] + .map((e) => Padding( + padding: const EdgeInsets.only(right: 4), + child: e, + )) + .toList(), + ), + ), + ], + ), + ); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b774f56..09381ad 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -23,25 +23,26 @@ environment: dependencies: flutter: sdk: flutter - cupertino_icons: ^1.0.2 - window_manager: ^0.0.5 + cupertino_icons: ^1.0.4 + window_manager: ^0.1.3 localstorage: ^4.0.0+1 - shared_preferences: ^2.0.7 - xdg_directories: 0.2.0 - gsettings: 0.2.0 - provider: ^6.0.0 - package_info_plus: ^1.0.6 + shared_preferences: ^2.0.12 + xdg_directories: ^0.2.0 + gsettings: 0.2.3 + provider: ^6.0.2 + package_info_plus: ^1.3.0 easy_localization: ^3.0.0 glass_kit: ^2.0.1 rect_getter: ^1.0.0 - path_provider: ^2.0.5 + path_provider: ^2.0.8 uuid: ^3.0.5 adaptive_scrollbar: ^2.1.0 flutter_platform_alert: ^0.2.1 cached_network_image: ^3.2.0 - url_launcher: ^6.0.17 + url_launcher: ^6.0.18 flutter_login: ^3.1.0 auto_size_text: ^3.0.0 + numeral: ^1.2.5 markdown_editor_ot: path: 3rd_party/markdown_editor_ot cherry_toast: @@ -54,8 +55,9 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - build_runner: 2.1.2 - source_gen: 1.1.0 + build_runner: 2.1.7 + source_gen: 1.2.1 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/app/resources/langs/en.json b/app/resources/langs/en.json index bcd5583..dabc42f 100644 --- a/app/resources/langs/en.json +++ b/app/resources/langs/en.json @@ -20,7 +20,13 @@ "tip": "Display help documentation" }, "market": { - "title": "Scheme market" + "title": "Scheme market", + "sort_types": { + "recommend": "Recommend", + "updated": "updated", + "likes": "likes", + "downloads": "downloads" + } }, "local_manager": { "title": "Local scheme management", @@ -66,19 +72,26 @@ } }, "operation": { - "add": "Add", + "add": "add", "delete": "delete", "duplicate": "duplicate", "apply": "apply", "paste": "paste", - "logout": "sign out" + "logout": "sign out", + "upload": "upload", + "download": "download", + "share": "share to market", + "like": "like" }, "str": { "null": "Null", "new_scheme": "New gesture scheme", "copy": "copy", "yes": "Yes", - "no": "No" + "no": "No", + "ok": "OK", + "share": "Share", + "cancel": "Cancel" }, "built_in_commands": { "ShowWorkspace": "ShowWorkspace", @@ -126,6 +139,27 @@ "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?" + }, + "login_for_upload": { + "title": "please login", + "description": "You need to login first to perform upload operations" + }, + "upload_and_share": { + "title": "Share the scheme at the same time?", + "description": "If you select [Share], other users can see this scheme and download it;\nIf you select [Cancel], you can still find this scheme in the [My Upload] list and share." + }, + "user_blocked_hint": { + "title": "The account has been blocked!", + "description": "If you have any questions, please contact me using email." + }, + "upload": { + "success": "Upload success ~", + "failed": "Upload failed.." + }, + "share": { + "title": "Are you sure to sharing?", + "description": "Other users can see this scheme and download it after share", + "success": "Share success" } }, "me": { @@ -135,6 +169,11 @@ "email_hint": "Please enter email", "password_hint": "Please enter 8-16-bit password", "email_error_hint": "Please enter your vaild email" + }, + "scheme_types": { + "uploaded": "Uploaded", + "downloaded": "Downloaded", + "liked": "Liked" } } } \ No newline at end of file diff --git a/app/resources/langs/zh-CN.json b/app/resources/langs/zh-CN.json index 3832629..d842369 100644 --- a/app/resources/langs/zh-CN.json +++ b/app/resources/langs/zh-CN.json @@ -20,7 +20,13 @@ "tip": "显示帮助文档" }, "market": { - "title": "方案市场" + "title": "方案市场", + "sort_types": { + "recommend": "推荐", + "updated": "最近更新", + "likes": "点赞数", + "downloads": "下载量" + } }, "local_manager": { "title": "本地方案管理", @@ -71,14 +77,21 @@ "duplicate": "复制", "apply": "应用", "paste": "粘贴", - "logout": "退出登录" + "logout": "退出登录", + "upload": "上传", + "download": "下载", + "share": "分享到市场", + "like": "点赞" }, "str": { "null": "无", "new_scheme": "新建手势方案", "copy": "副本", "yes": "是", - "no": "否" + "no": "否", + "ok": "好的", + "share": "分享", + "cancel": "放弃" }, "built_in_commands": { "ShowWorkspace": "显示工作区", @@ -126,6 +139,27 @@ "description_for_startup": "点击[{yes}]查看,点击[{no}]忽略本次更新", "title_already_latest": "已经是最新版本~", "description_for_manual": "是否前去官网查看?" + }, + "login_for_upload": { + "title": "请登录", + "description": "您需要先登录才能进行上传操作" + }, + "upload_and_share": { + "title": "是否同时分享到方案市场?", + "description": "如果选择[分享],其他用户可以看到本方案并下载使用;\n如果选择[放弃],您仍可以稍后在[我的上传]列表中找到本方案进行操作。" + }, + "user_blocked_hint": { + "title": "该账号已被封禁!", + "description": "如有疑问请通过发送邮件联系" + }, + "upload": { + "success": "上传成功~", + "failed": "上传失败。。" + }, + "share": { + "title": "确定分享?", + "description": "分享后其他用户可以看到本方案并下载使用", + "success": "分享成功" } }, "me": { @@ -135,6 +169,11 @@ "email_hint": "请输入邮箱", "password_hint": "请输入8-16位密码", "email_error_hint": "请输入正确的邮箱" + }, + "scheme_types": { + "uploaded": "我的上传", + "downloaded": "我的下载", + "liked": "我的点赞" } } } \ No newline at end of file