diff --git a/.env.example b/.env.example index 22a8172..bf99b90 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,7 @@ PEXELS_API_KEY= DB_HOST= DB_USERNAME= DB_DATABASE= -DB_PASSWORD= \ No newline at end of file +DB_PASSWORD= + +# Required for generating & validating auth tokens +APP_KEY= \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index effd377..6b18002 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,11 @@ jobs: uses: actions/checkout@v3 - uses: dart-lang/setup-dart@v1.3 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - uses: bluefireteam/melos-action@v3 + - name: Check formatting run: dart format . --line-length=120 --set-exit-if-changed @@ -27,6 +32,7 @@ jobs: - name: Check linting run: | dart run build_runner build + cd packages/shared && dart run build_runner build --delete-conflicting-outputs dart analyze . --fatal-infos test: @@ -37,14 +43,19 @@ jobs: uses: actions/checkout@v3 - uses: dart-lang/setup-dart@v1.3 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - uses: bluefireteam/melos-action@v3 - name: Bootstrap run: | dart pub global activate coverage dart run build_runner build --delete-conflicting-outputs + cd packages/shared && dart run build_runner build --delete-conflicting-outputs - name: Setup Test Database - run: dart run yaroorm migrate + run: dart run yaroorm_cli migrate --connection=local - name: Run Tests run: | diff --git a/README.md b/README.md index 7ff4a40..e983dfc 100644 --- a/README.md +++ b/README.md @@ -10,24 +10,30 @@ $ dart pub get && dart run build_runner build --delete-conflicting-outputs ### Migrate Database +- For local dev, execute migrations on sqlite database using the command below + +```shell +dart run yaroorm_cli migrate --connection=local +``` + +- For production database, you can run this. + ```shell $ dart run yaroorm migrate ``` ```shell ┌──────────────────────────────┬──────────────────────────────┐ -│ Migration │ Status │ -├──────────────────────────────┼──────────────────────────────┤ -│ create_users_table │ ✅ migrated │ +│ Migration │ Status │ ├──────────────────────────────┼──────────────────────────────┤ -│ create_articles_table │ ✅ migrated │ +│ initial_table_setup │ ✅ migrated │ └──────────────────────────────┴──────────────────────────────┘ ``` ### Start Server ```shell -$ dart run --enable-asserts +$ dart run ``` ### Tests diff --git a/analysis_options.yaml b/analysis_options.yaml index 0ccbb2f..9e88bb9 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,7 @@ linter: analyzer: exclude: - "**.reflectable.dart" + - frontend/ # For more information about the core and recommended set of lints, see # https://dart.dev/go/core-lints diff --git a/database/config.dart b/database/config.dart deleted file mode 100644 index 8b13789..0000000 --- a/database/config.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/database/database.dart b/database/database.dart index e8d92e4..55c9df0 100644 --- a/database/database.dart +++ b/database/database.dart @@ -1,8 +1,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:backend/src/models/article.dart'; -import 'package:backend/src/models/user.dart'; +import 'package:backend/src/models.dart'; import 'package:backend/src/utils/config.dart' as config; import 'package:yaroorm/yaroorm.dart'; @@ -10,8 +9,8 @@ import 'migrations/2024_04_20_003612_initial_setup.dart' as _m0; void initializeORM() { /// Add Type Definitions to Query Runner - Query.addTypeDef(userTypeDef); - Query.addTypeDef
(articleTypeDef); + Query.addTypeDef(serveruserTypeDef); + Query.addTypeDef(serverarticleTypeDef); /// Configure Migrations Order DB.migrations.addAll([ diff --git a/database/migrations/2024_04_20_003612_initial_setup.dart b/database/migrations/2024_04_20_003612_initial_setup.dart index 013aa1d..4689ff3 100644 --- a/database/migrations/2024_04_20_003612_initial_setup.dart +++ b/database/migrations/2024_04_20_003612_initial_setup.dart @@ -1,18 +1,17 @@ -import 'package:backend/src/models/article.dart'; -import 'package:backend/src/models/user.dart'; +import 'package:backend/src/models.dart'; import 'package:yaroorm/yaroorm.dart'; class InitialTableSetup extends Migration { @override void up(List schemas) { - schemas.addAll([UserSchema, ArticleSchema]); + schemas.addAll([ServerUserSchema, ServerArticleSchema]); } @override void down(List schemas) { schemas.addAll([ - Schema.dropIfExists(UserSchema), - Schema.dropIfExists(ArticleSchema), + Schema.dropIfExists(ServerUserSchema), + Schema.dropIfExists(ServerArticleSchema), ]); } } diff --git a/lib/app/middlewares/api_auth_middleware.dart b/lib/app/middlewares/api_auth_middleware.dart index 31e72ed..2d83981 100644 --- a/lib/app/middlewares/api_auth_middleware.dart +++ b/lib/app/middlewares/api_auth_middleware.dart @@ -1,6 +1,6 @@ +import 'package:backend/src/models.dart'; import 'package:pharaoh/pharaoh_next.dart'; -import '../../src/models/user.dart'; import '../../src/services/auth_service.dart'; class ApiAuthMiddleware extends ClassMiddleware { @@ -13,7 +13,7 @@ class ApiAuthMiddleware extends ClassMiddleware { final userId = _authService.validateRequest(req); if (userId == null) return next(res.unauthorized()); - final user = await UserQuery.findById(userId); + final user = await ServerUserQuery.findById(userId); if (user == null) return next(res.unauthorized()); return next(req..auth = user); diff --git a/lib/app/middlewares/core_middleware.dart b/lib/app/middlewares/core_middleware.dart index 96b4ac1..fadc80c 100644 --- a/lib/app/middlewares/core_middleware.dart +++ b/lib/app/middlewares/core_middleware.dart @@ -1,6 +1,7 @@ -import 'package:logger/logger.dart'; +import 'package:logging/logging.dart'; import 'package:pharaoh/pharaoh.dart'; import 'package:pharaoh/pharaoh_next.dart'; +import 'package:shared/shared.dart'; class CoreMiddleware extends ClassMiddleware { late Middleware _webMdw; @@ -11,17 +12,27 @@ class CoreMiddleware extends ClassMiddleware { final cookieConfig = app.instanceOf(); final cookieParserMdw = cookieParser(opts: cookieConfig); - /// setup logger - loggerMdw(Request req, Response res, NextFunction next) { - _logger.i('Req: ${req.method.name}:${req.path}'); - next(); - } + corsMiddleware(Request req, Response res, NextFunction next) { + res = res + ..header('Access-Control-Allow-Origin', appEnv.frontendURL.toString()) + ..header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') + ..header('Access-Control-Allow-Headers', 'Content-Type, Authorization') + ..header('Access-Control-Allow-Credentials', 'true') + ..header('Access-Control-Max-Age', '3600'); + + if (req.method == HTTPMethod.OPTIONS) { + return next(res.status(200).end()); + } - if (app.config.environment == 'development') { - _webMdw = loggerMdw.chain(cookieParserMdw); - } else { - _webMdw = cookieParserMdw; + return next(res); } + + _webMdw = corsMiddleware.chain(cookieParserMdw).chain((req, res, next) { + if (isTestMode) return next(); + + _logger.fine('${req.method.name}:${req.path}'); + next(); + }); } @override diff --git a/lib/app/providers/provide_core.dart b/lib/app/providers/provide_core.dart index 57c90fd..04d20d6 100644 --- a/lib/app/providers/provide_core.dart +++ b/lib/app/providers/provide_core.dart @@ -1,5 +1,5 @@ import 'package:backend/src/services/services.dart'; -import 'package:logger/logger.dart'; +import 'package:logging/logging.dart'; import 'package:pharaoh/pharaoh.dart'; import 'package:pharaoh/pharaoh_next.dart'; @@ -12,15 +12,12 @@ class CoreProvider extends ServiceProvider { app.singleton(AuthService(app.config.key, app.config.url)); - app.singleton(Logger(printer: PrettyPrinter(), filter: _CustomLogFilter(app.config.isDebug))); - } -} - -class _CustomLogFilter extends LogFilter { - final bool loggingEnabled; + Logger.root + ..level = Level.ALL + ..onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); - _CustomLogFilter(this.loggingEnabled); - - @override - bool shouldLog(LogEvent event) => loggingEnabled; + app.singleton(Logger(config.environment)); + } } diff --git a/lib/app/providers/provide_database.dart b/lib/app/providers/provide_database.dart index 754cf88..ca96f36 100644 --- a/lib/app/providers/provide_database.dart +++ b/lib/app/providers/provide_database.dart @@ -1,4 +1,4 @@ -import 'package:logger/logger.dart'; +import 'package:logging/logging.dart'; import 'package:pharaoh/pharaoh_next.dart'; import 'package:yaroorm/yaroorm.dart'; @@ -8,6 +8,6 @@ class DatabaseServiceProvider extends ServiceProvider { await DB.defaultDriver.connect(); final logger = app.instanceOf(); - logger.d('Using ${DB.defaultConnection.info.name} database'); + logger.info('Using ${DB.defaultConnection.info.name} database'); } } diff --git a/lib/app/providers/provide_routes.dart b/lib/app/providers/provide_routes.dart index 1ee7f7b..34f7418 100644 --- a/lib/app/providers/provide_routes.dart +++ b/lib/app/providers/provide_routes.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:pharaoh/pharaoh_next.dart'; import '../routes/api.dart' as api; -import '../routes/api.dart'; import '../routes/web.dart' as web; class RouteServiceProvider extends ServiceProvider { @@ -14,11 +13,8 @@ class RouteServiceProvider extends ServiceProvider { /*|-------------------------------------------------------------------------- | API Routes |--------------------------------------------------------------------------*/ - - publicRoutes, - - // - Route.middleware('api:auth').group('api', api.authRoutes), + api.publicRoutes, + api.authRoutes, /*|-------------------------------------------------------------------------- | Web Routes diff --git a/lib/app/routes/api.dart b/lib/app/routes/api.dart index 18c7c11..a399d71 100644 --- a/lib/app/routes/api.dart +++ b/lib/app/routes/api.dart @@ -14,17 +14,17 @@ final publicRoutes = Route.group('api', [ ]), ]); -List authRoutes = [ - /// Users +final authRoutes = Route.middleware('api:auth').group('api', [ + // Users Route.group('users', [ Route.get('/', (UserController, #index)), Route.get('/me', (UserController, #currentUser)), ]), - /// Articles + // Articles Route.group('articles', [ Route.post('/', (ArticleController, #create)), Route.put('/', (ArticleController, #update)), Route.delete('/', (ArticleController, #delete)), ]), -]; +]); diff --git a/lib/backend.dart b/lib/backend.dart index 61316ed..9da6cf7 100644 --- a/lib/backend.dart +++ b/lib/backend.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:backend/src/utils/config.dart'; +import 'package:logging/logging.dart'; import 'package:pharaoh/pharaoh_next.dart'; import 'app/middlewares/core_middleware.dart'; @@ -13,7 +14,7 @@ export 'src/dto/dto.dart'; final blogApp = App(appConfig); -class App extends ApplicationFactory { +class App extends ApplicationFactory with AppInstance { App(super.appConfig); @override @@ -41,6 +42,8 @@ class App extends ApplicationFactory { return response.json(exception.errorBody, statusCode: HttpStatus.badRequest); } + app.instanceOf().severe(exception, null, error.trace); + return super.onApplicationException(error, request, response); } } diff --git a/lib/src/controllers/article_controller.dart b/lib/src/controllers/article_controller.dart index 668c442..dbf2a9f 100644 --- a/lib/src/controllers/article_controller.dart +++ b/lib/src/controllers/article_controller.dart @@ -1,9 +1,13 @@ +import 'dart:isolate'; + import 'package:backend/src/dto/article_dto.dart'; +import 'package:backend/src/models.dart'; import 'package:backend/src/services/services.dart'; import 'package:pharaoh/pharaoh_next.dart'; +import 'package:shared/models.dart'; +import 'package:yaroorm/yaroorm.dart'; -import '../models/article.dart'; -import '../models/user.dart'; +import '../utils/utils.dart'; class ArticleController extends HTTPController { final ArticleService _articleService; @@ -22,13 +26,15 @@ class ArticleController extends HTTPController { } Future create(@body CreateArticleDTO data) async { - var imageUrl = data.imageUrl; - if (app.config.isDebug) { - imageUrl ??= 'https://dart.dev/assets/shared/dart-logo-for-shares.png'; - } + final imageUrl = data.imageUrl ?? await Isolate.run(() => getRandomImage(data.title)); - final article = await _articleService.createArticle(user, data, imageUrl: imageUrl); - return jsonResponse(_articleResponse(article)); + final article = await user.articles.insert(NewServerArticleForServerUser( + title: data.title, + description: data.description, + imageUrl: Value(imageUrl), + )); + + return response.json(_articleResponse(article)); } Future update(@param int articleId, @body CreateArticleDTO data) async { @@ -44,5 +50,5 @@ class ArticleController extends HTTPController { Map _articleResponse(Article article) => {'article': article.toJson()}; - User get user => request.auth as User; + ServerUser get user => request.auth as ServerUser; } diff --git a/lib/src/controllers/auth_controller.dart b/lib/src/controllers/auth_controller.dart index 68d0331..8d90397 100644 --- a/lib/src/controllers/auth_controller.dart +++ b/lib/src/controllers/auth_controller.dart @@ -5,7 +5,7 @@ import 'package:pharaoh/pharaoh.dart'; import 'package:pharaoh/pharaoh_next.dart'; import '../dto/dto.dart'; -import '../models/user.dart'; +import '../models.dart'; import '../services/services.dart'; class AuthController extends HTTPController { @@ -14,7 +14,7 @@ class AuthController extends HTTPController { AuthController(this._authService); Future login(@body LoginUserDTO data) async { - final user = await UserQuery.findByEmail(data.email); + final user = await ServerUserQuery.findByEmail(data.email); if (user == null) return invalidLogin; final match = BCrypt.checkpw(data.password, user.password); @@ -27,7 +27,7 @@ class AuthController extends HTTPController { } Future register(@body CreateUserDTO data) async { - final userExists = await UserQuery.where((user) => user.email(data.email)).exists(); + final userExists = await ServerUserQuery.where((user) => user.email(data.email)).exists(); if (userExists) { return response.json( _makeError(['Email already taken']), @@ -36,7 +36,7 @@ class AuthController extends HTTPController { } final hashedPass = BCrypt.hashpw(data.password, BCrypt.gensalt()); - final newUser = await UserQuery.insert(NewUser( + final newUser = await ServerUserQuery.insert(NewServerUser( name: data.name, email: data.email, password: hashedPass, @@ -47,7 +47,7 @@ class AuthController extends HTTPController { Response get invalidLogin => response.unauthorized(data: _makeError(['Email or Password not valid'])); - Map _userResponse(User user) => {'user': user.toJson()}; + Map _userResponse(ServerUser user) => {'user': user.toJson()}; Map _makeError(List errors) => {'errors': errors}; } diff --git a/lib/src/controllers/user_controller.dart b/lib/src/controllers/user_controller.dart index e399b00..4a42a03 100644 --- a/lib/src/controllers/user_controller.dart +++ b/lib/src/controllers/user_controller.dart @@ -1,6 +1,6 @@ +import 'package:backend/src/models.dart'; import 'package:pharaoh/pharaoh_next.dart'; - -import '../models/user.dart'; +import 'package:shared/models.dart'; class UserController extends HTTPController { Future currentUser() async { @@ -9,12 +9,12 @@ class UserController extends HTTPController { } Future index() async { - final result = await UserQuery.findMany(); + final result = await ServerUserQuery.findMany(); return jsonResponse(result); } Future show(@param int userId) async { - final user = await UserQuery.findById(userId); + final user = await ServerUserQuery.findById(userId); if (user == null) return notFound('User not found'); return jsonResponse({'user': user.toJson()}); } diff --git a/lib/src/models.dart b/lib/src/models.dart new file mode 100644 index 0000000..dfb53f3 --- /dev/null +++ b/lib/src/models.dart @@ -0,0 +1,53 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:shared/models.dart'; +import 'package:yaroorm/yaroorm.dart'; + +part 'models.g.dart'; + +@table +@JsonSerializable(fieldRename: FieldRename.snake) +class ServerUser extends User with Entity { + @JsonKey(defaultValue: '', includeToJson: false) + final String password; + + ServerUser( + @primaryKey super.id, + super.name, + super.email, { + required this.password, + @createdAtCol required super.createdAt, + @updatedAtCol required super.updatedAt, + }) { + initialize(); + } + + HasMany get articles => hasMany(#articles); + + @override + Map toJson() => _$ServerUserToJson(this); + + factory ServerUser.fromJson(Map json) => _$ServerUserFromJson(json); +} + +@table +@JsonSerializable(fieldRename: FieldRename.snake) +class ServerArticle extends Article with Entity { + ServerArticle( + @primaryKey super.id, + super.title, + @bindTo(ServerUser, onDelete: ForeignKeyAction.cascade) super.ownerId, + super.description, { + super.imageUrl, + @createdAtCol required super.createdAt, + @updatedAtCol required super.updatedAt, + }) { + initialize(); + } + + BelongsTo get owner => belongsTo(#owner); + + @override + Map toJson() => _$ServerArticleToJson(this); + + factory ServerArticle.fromJson(Map json) => _$ServerArticleFromJson(json); +} diff --git a/lib/src/services/article_service.dart b/lib/src/services/article_service.dart index 48bc057..fec2817 100644 --- a/lib/src/services/article_service.dart +++ b/lib/src/services/article_service.dart @@ -1,56 +1,38 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:pharaoh/pharaoh_next.dart'; +import 'package:backend/src/models.dart'; +import 'package:shared/models.dart'; import 'package:yaroorm/yaroorm.dart'; -import 'package:http/http.dart' as http; import 'package:backend/src/dto/article_dto.dart'; -import 'package:backend/src/models/article.dart'; - -import '../models/user.dart'; class ArticleService { Future> getArticles({int? ownerId}) async { if (ownerId == null) { - return ArticleQuery.findMany( + return ServerArticleQuery.findMany( orderBy: [ - OrderArticleBy.title(), - OrderArticleBy.updatedAt(order: OrderDirection.desc), + OrderServerArticleBy.title(), + OrderServerArticleBy.updatedAt(order: OrderDirection.desc), ], ); } - return ArticleQuery.where((article) => article.ownerId(ownerId)).findMany( + return ServerArticleQuery.where((article) => article.ownerId(ownerId)).findMany( orderBy: [ - OrderArticleBy.updatedAt(order: OrderDirection.desc), + OrderServerArticleBy.updatedAt(order: OrderDirection.desc), ], ); } - Future getArticle(int articleId) => ArticleQuery.findById(articleId); - - Future
createArticle(User user, CreateArticleDTO data, {String? imageUrl}) async { - imageUrl ??= await Isolate.run(() => getRandomImage(data.title)); - - return await ArticleQuery.insert(NewArticle( - title: data.title, - ownerId: user.id, - description: data.description, - imageUrl: Value.absentIfNull(imageUrl), - )); - } + Future getArticle(int articleId) => ServerArticleQuery.findById(articleId); Future updateArticle(User user, int articleId, CreateArticleDTO dto) async { - final query = ArticleQuery.where((article) => and([ + final query = ServerArticleQuery.where((article) => and([ article.id(articleId), article.ownerId(user.id), ])); if (!(await query.exists())) return null; - await query.update(UpdateArticle( + await query.update(UpdateServerArticle( title: Value.absentIfNull(dto.title), description: Value.absentIfNull(dto.description), imageUrl: Value.absentIfNull(dto.imageUrl), @@ -60,21 +42,9 @@ class ArticleService { } Future deleteArticle(int userId, int articleId) { - return ArticleQuery.where((article) => and([ + return ServerArticleQuery.where((article) => and([ article.id(articleId), article.ownerId(userId), ])).delete(); } - - Future getRandomImage(String searchText) async { - try { - final response = await http.get( - Uri.parse('https://api.pexels.com/v1/search?query=$searchText&per_page=1'), - headers: {HttpHeaders.authorizationHeader: env('PEXELS_API_KEY', '')}, - ).timeout(const Duration(seconds: 2)); - final result = await Isolate.run(() => jsonDecode(response.body)) as Map; - return result['photos'][0]['src']['medium']; - } catch (_) {} - return null; - } } diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index a959c0e..6262bf8 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -3,8 +3,7 @@ import 'dart:io'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:collection/collection.dart'; import 'package:pharaoh/pharaoh.dart'; - -import '../models/user.dart'; +import 'package:shared/models.dart'; class AuthService { final String jwtKey; diff --git a/lib/src/utils/config.dart b/lib/src/utils/config.dart index 86c04f1..23d0d25 100644 --- a/lib/src/utils/config.dart +++ b/lib/src/utils/config.dart @@ -1,16 +1,19 @@ -import 'package:backend/src/utils/utils.dart'; import 'package:path/path.dart' as path; import 'package:pharaoh/pharaoh_next.dart'; +import 'package:shared/shared.dart'; import 'package:uuid/v4.dart'; import 'package:yaroorm/yaroorm.dart'; -enum AppEnvironment { - local(), - staging(), - prod(); - - const AppEnvironment(); +final appConfig = AppConfig( + name: 'Dart Blog', + environment: env('APP_ENV', appEnv.name), + isDebug: appEnv == AppEnvironment.local, + url: env('APP_URL', 'http://localhost'), + port: env('PORT', 80), + key: env('APP_KEY', UuidV4().generate()), +); +extension on AppEnvironment { DatabaseConnection get dbCon => switch (this) { AppEnvironment.prod => DatabaseConnection( AppEnvironment.prod.name, @@ -30,19 +33,8 @@ enum AppEnvironment { }; } -final currentEnv = isDebugMode ? AppEnvironment.local : AppEnvironment.prod; - -final appConfig = AppConfig( - name: 'Dart Blog', - environment: env('APP_ENV', currentEnv.name), - isDebug: env('APP_DEBUG', currentEnv == AppEnvironment.local), - url: env('APP_URL', 'http://localhost'), - port: env('PORT', 80), - key: env('APP_KEY', UuidV4().generate()), -); - @DB.useConfig final config = YaroormConfig( - currentEnv.name, + appEnv.name, connections: AppEnvironment.values.map((e) => e.dbCon).toList(), ); diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 4a18a46..d4e86f5 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -1,8 +1,23 @@ -bool get isDebugMode { - var isDebug = false; - assert(() { - isDebug = true; - return true; - }()); - return isDebug; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:http/http.dart' as http; +import 'package:pharaoh/pharaoh_next.dart'; +import 'package:shared/shared.dart'; + +Future getRandomImage(String searchText) async { + if (isTestMode) { + return 'https://dart.dev/assets/shared/dart-logo-for-shares.png'; + } + + try { + final response = await http.get( + Uri.parse('https://api.pexels.com/v1/search?query=$searchText&per_page=1'), + headers: {HttpHeaders.authorizationHeader: env('PEXELS_API_KEY', '')}, + ).timeout(const Duration(seconds: 2)); + final result = await Isolate.run(() => jsonDecode(response.body)) as Map; + return result['photos'][0]['src']['medium']; + } catch (_) {} + return null; } diff --git a/melos.yaml b/melos.yaml new file mode 100644 index 0000000..a8670bf --- /dev/null +++ b/melos.yaml @@ -0,0 +1,6 @@ +name: 'dart_blog' + +packages: + - 'packages/shared' + - 'packages/frontend' + - '.' \ No newline at end of file diff --git a/melos_backend.iml b/melos_backend.iml new file mode 100644 index 0000000..389d07a --- /dev/null +++ b/melos_backend.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/melos_dart_blog.iml b/melos_dart_blog.iml new file mode 100644 index 0000000..9681559 --- /dev/null +++ b/melos_dart_blog.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/frontend/.fvmrc b/packages/frontend/.fvmrc new file mode 100644 index 0000000..6db5ff3 --- /dev/null +++ b/packages/frontend/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.24.3", + "flavors": {} +} diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 0000000..64e4ae6 --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ +.vscode/ + + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +**.g.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/packages/frontend/.metadata b/packages/frontend/.metadata new file mode 100644 index 0000000..2d1be89 --- /dev/null +++ b/packages/frontend/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2663184aa79047d0a33a14a3b607954f8fdd8730" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: android + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: ios + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: linux + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: macos + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: web + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: windows + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/frontend/README.md b/packages/frontend/README.md new file mode 100644 index 0000000..c43db62 --- /dev/null +++ b/packages/frontend/README.md @@ -0,0 +1,16 @@ +# web + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/frontend/analysis_options.yaml b/packages/frontend/analysis_options.yaml new file mode 100644 index 0000000..b260051 --- /dev/null +++ b/packages/frontend/analysis_options.yaml @@ -0,0 +1,20 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: +# The lint rules applied to this project can be customized in the +# section below to disable rules from the `package:flutter_lints/flutter.yaml` +# included above or to enable additional rules. A list of all available lints +# and their documentation is published at https://dart.dev/lints. + +analyzer: + exclude: + - "**.g.dart" \ No newline at end of file diff --git a/packages/frontend/lib/auth/auth.dart b/packages/frontend/lib/auth/auth.dart new file mode 100644 index 0000000..9f3556c --- /dev/null +++ b/packages/frontend/lib/auth/auth.dart @@ -0,0 +1,3 @@ +export 'pages/login_page.dart'; +export 'pages/register_page.dart'; +export 'auth_header.dart'; diff --git a/packages/frontend/lib/auth/auth_header.dart b/packages/frontend/lib/auth/auth_header.dart new file mode 100644 index 0000000..7503688 --- /dev/null +++ b/packages/frontend/lib/auth/auth_header.dart @@ -0,0 +1,47 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/data/providers/auth_provider.dart'; +import 'package:frontend/main.dart'; +import 'package:frontend/utils/provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared/models.dart'; + +class AuthHeaderOptions extends StatelessWidget { + const AuthHeaderOptions({super.key}); + + @override + Widget build(BuildContext context) { + final auth = context.read(); + const spacing = SizedBox(width: 24); + + return StreamBuilder>( + stream: auth.stream, + initialData: auth.lastEvent, + builder: (context, snapshot) { + final user = auth.lastEvent?.data; + final isLoading = snapshot.data?.state == ProviderState.loading; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isLoading && user == null) ...[ + Button(child: const Text('Login '), onPressed: () => router.push('/login')), + spacing, + Button(child: const Text('Register'), onPressed: () => router.push('/register')), + ], + if (user != null) ...[ + Text('Welcome, ${user.name.split(' ').first}'), + spacing, + Button( + child: const Text('Logout'), + onPressed: () { + auth.logout(); + router.pushReplacement('/'); + }, + ), + ], + ], + ); + }, + ); + } +} diff --git a/packages/frontend/lib/auth/pages/auth_layout.dart b/packages/frontend/lib/auth/pages/auth_layout.dart new file mode 100644 index 0000000..d356dc5 --- /dev/null +++ b/packages/frontend/lib/auth/pages/auth_layout.dart @@ -0,0 +1,57 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/data/providers/auth_provider.dart'; +import 'package:frontend/utils/misc.dart'; +import 'package:frontend/utils/provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared/models.dart'; + +class BaseAuthLayout extends StatefulWidget { + final Widget Function(AuthProvider auth, BaseAuthLayoutState layout) child; + + const BaseAuthLayout({super.key, required this.child}); + + @override + State createState() => BaseAuthLayoutState(); +} + +class BaseAuthLayoutState extends State { + bool _showingLoading = false; + + @override + Widget build(BuildContext context) { + final authProv = context.read(); + return ScaffoldPage( + padding: EdgeInsets.zero, + content: Stack( + children: [ + Container(color: Colors.grey), + Center( + child: SizedBox( + width: 320, + child: Card( + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_showingLoading) const SizedBox(width: double.maxFinite, child: ProgressBar()), + Container( + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16), + child: widget.child(authProv, this), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + void setLoading(bool show) => setState(() => _showingLoading = show); + + void handleErrors(ProviderEvent event) { + if (event.state != ProviderState.error) return; + showError(context, event.errorMessage!); + } +} diff --git a/packages/frontend/lib/auth/pages/login_page.dart b/packages/frontend/lib/auth/pages/login_page.dart new file mode 100644 index 0000000..906a644 --- /dev/null +++ b/packages/frontend/lib/auth/pages/login_page.dart @@ -0,0 +1,88 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/main.dart'; + +import 'auth_layout.dart'; + +const _spacing = SizedBox(height: 18); + +class LoginPage extends StatefulWidget { + final String? returnUrl; + + const LoginPage({super.key, this.returnUrl}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + String? email; + String? password; + + @override + Widget build(BuildContext context) { + final themeData = FluentTheme.of(context); + + return BaseAuthLayout( + child: (auth, layout) { + loginAction(String email, String password) async { + layout.setLoading(true); + await auth.login(email, password); + + final lastEvent = auth.lastEvent!; + if (lastEvent.data != null) { + return router.pushReplacement(widget.returnUrl ?? '/'); + } else { + router.pushReplacement('/login'); + } + + layout + ..setLoading(false) + ..handleErrors(lastEvent); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InfoLabel( + label: 'Email', + child: TextBox(onChanged: (value) => setState(() => email = value)), + ), + _spacing, + InfoLabel( + label: 'Password', + child: PasswordBox(onChanged: (value) => setState(() => password = value)), + ), + _spacing, + GestureDetector( + onTap: () => router.push('/register'), + child: Text.rich( + TextSpan( + text: 'No account? ', + children: [ + TextSpan( + text: 'Create one!', + style: themeData.typography.body?.apply(color: themeData.accentColor.dark)), + ], + ), + ), + ), + const SizedBox(height: 32), + Row( + children: [ + const Expanded(child: SizedBox.shrink()), + FilledButton( + style: ButtonStyle( + shape: WidgetStateProperty.all(const RoundedRectangleBorder(borderRadius: BorderRadius.zero)), + ), + onPressed: [email, password].contains(null) ? null : () => loginAction(email!, password!), + child: const Text('Sign in'), + ) + ], + ) + ], + ); + }, + ); + } +} diff --git a/packages/frontend/lib/auth/pages/register_page.dart b/packages/frontend/lib/auth/pages/register_page.dart new file mode 100644 index 0000000..54b7c0d --- /dev/null +++ b/packages/frontend/lib/auth/pages/register_page.dart @@ -0,0 +1,68 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/main.dart'; + +import 'auth_layout.dart'; + +const _spacing = SizedBox(height: 24); + +class RegisterPage extends StatefulWidget { + const RegisterPage({super.key}); + + @override + State createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { + String? name; + String? email; + String? password; + + @override + Widget build(BuildContext context) { + return BaseAuthLayout( + child: (auth, layout) { + registerAction(String name, String email, String password) async { + layout.setLoading(true); + final success = await auth.register(name, email, password); + router.pushReplacement(success ? '/login' : '/register'); + + layout + ..setLoading(false) + ..handleErrors(auth.lastEvent!); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InfoLabel( + label: 'Display Name', + child: TextBox( + keyboardType: TextInputType.name, onChanged: (value) => setState(() => name = value.trim()))), + _spacing, + InfoLabel( + label: 'Email', + child: TextBox( + keyboardType: TextInputType.emailAddress, + onChanged: (value) => setState(() => email = value.trim()))), + _spacing, + InfoLabel(label: 'Password', child: PasswordBox(onChanged: (value) => setState(() => password = value))), + const SizedBox(height: 28), + Row( + children: [ + const Expanded(child: SizedBox.shrink()), + FilledButton( + style: ButtonStyle( + shape: WidgetStateProperty.all(const RoundedRectangleBorder(borderRadius: BorderRadius.zero)), + ), + onPressed: + [name, email, password].contains(null) ? null : () => registerAction(name!, email!, password!), + child: const Text('Register'), + ) + ], + ) + ], + ); + }, + ); + } +} diff --git a/packages/frontend/lib/blog/blog.dart b/packages/frontend/lib/blog/blog.dart new file mode 100644 index 0000000..b7a6d97 --- /dev/null +++ b/packages/frontend/lib/blog/blog.dart @@ -0,0 +1,63 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/data/providers/article_provider.dart'; +import 'package:frontend/data/providers/auth_provider.dart'; +import 'package:frontend/utils/misc.dart'; +import 'package:provider/provider.dart'; + +import 'widgets/article_items.dart'; + +class BlogPage extends StatefulWidget { + const BlogPage({super.key}); + + @override + State createState() => _BlogPageState(); +} + +class _BlogPageState extends State { + late AuthProvider _authProvider; + late ArticleProvider _articleProvider; + + @override + void initState() { + super.initState(); + + _authProvider = context.read(); + _articleProvider = context.read(); + + _fetchData(); + } + + void _fetchData() async { + await Future.wait([ + _authProvider.getUser(), + _articleProvider.fetchArticles(), + ]); + } + + @override + Widget build(BuildContext context) { + return WebConstrainedLayout( + child: ScaffoldPage.scrollable(children: const [BlogArticlesWidget()]), + ); + } +} + +class WebConstrainedLayout extends StatelessWidget { + final Widget child; + const WebConstrainedLayout({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final borderSide = BorderSide(color: blogColor.withOpacity(0.1)); + + return Container( + alignment: Alignment.topCenter, + child: Container( + constraints: const BoxConstraints(maxWidth: 1300), + decoration: BoxDecoration(border: Border(left: borderSide, right: borderSide, top: borderSide)), + margin: const EdgeInsets.all(24), + child: child, + ), + ); + } +} diff --git a/packages/frontend/lib/blog/blog_detail.dart b/packages/frontend/lib/blog/blog_detail.dart new file mode 100644 index 0000000..05516dc --- /dev/null +++ b/packages/frontend/lib/blog/blog_detail.dart @@ -0,0 +1,153 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/data/providers/article_provider.dart'; +import 'package:frontend/data/providers/auth_provider.dart'; +import 'package:frontend/main.dart'; +import 'package:frontend/utils/misc.dart'; +import 'package:markdown_widget/markdown_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:timeago/timeago.dart' as timeago; + +import 'widgets/article_base_layout.dart'; + +class BlogDetail extends StatelessWidget { + final String articleId; + + const BlogDetail(this.articleId, {super.key}); + + @override + @override + Widget build(BuildContext context) { + return ArticleBaseLayout( + articleId: int.tryParse(articleId), + child: (detail, layout) { + final currentUser = context.read().user; + final articleProv = context.read(); + + final article = detail.article; + final owner = detail.owner; + + if (article == null) { + if (detail.isLoading) return loadingView(); + if (detail.hasError) return errorView(message: detail.errorMessage); + return const SizedBox.shrink(); + } + + const footerStyle = TextStyle(fontSize: 12, fontWeight: FontWeight.w300); + final isPostOwner = currentUser != null && owner != null && currentUser.id == owner.id; + + final imageHost = article.imageUrl == null ? null : Uri.tryParse(article.imageUrl!)?.host; + const spacing = SizedBox(height: 24); + + return Column( + children: [ + PageHeader( + title: Text(article.title, style: const TextStyle(color: Colors.black)), + commandBar: CommandBar( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, + primaryItems: [ + if (isPostOwner) + CommandBarButton( + icon: const Icon(FluentIcons.edit), + label: const Text('Edit'), + onPressed: () => router.pushReplacement('/posts/${article.id}/edit'), + ), + if (isPostOwner) + CommandBarButton( + icon: Icon(FluentIcons.delete, color: Colors.red), + label: Text( + 'Delete', + style: TextStyle(color: Colors.red), + ), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return ContentDialog( + title: const Text('Delete Blog permanently?'), + content: const Text( + 'If you delete this file, you won\'t be able to recover it. Do you want to delete it?', + ), + actions: [ + FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(Colors.red), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + )), + onPressed: () async { + await articleProv + .deleteArticle(int.tryParse(articleId)!) + .then((value) => router.pushReplacement('/')); + }, + child: const Text("Delete"), + ), + FilledButton( + style: ButtonStyle( + shape: WidgetStateProperty.all( + const RoundedRectangleBorder(borderRadius: BorderRadius.zero))), + onPressed: () => router.pop(context), + child: const Text("Cancel"), + ), + ], + ); + }); + }, + ), + ], + ), + padding: 0, + ), + Divider( + style: DividerThemeData( + thickness: 0.2, + decoration: BoxDecoration(border: Border(top: BorderSide(color: Colors.grey.withOpacity(0.05)))), + ), + ), + spacing, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 400, + height: 250, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded(child: imageView(article.imageUrl!)), + if (imageHost != null) ...[ + const SizedBox(height: 8), + Text(imageHost, style: const TextStyle(fontWeight: FontWeight.w300, fontSize: 12)), + ] + ], + ), + ), + const SizedBox(width: 40), + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 350), + alignment: Alignment.topRight, + child: MarkdownWidget(data: article.description, shrinkWrap: true), + ), + ), + ], + ), + Container( + height: 40, + decoration: BoxDecoration( + border: Border(top: BorderSide(color: Colors.grey.withOpacity(0.5))), + ), + child: Row( + children: [ + Text('Last Updated: ${timeago.format(article.updatedAt)}', style: footerStyle), + const Spacer(), + if (owner != null) Text('Author: ${owner.name}', style: footerStyle) + ], + ), + ) + ], + ); + }, + ); + } +} diff --git a/packages/frontend/lib/blog/widgets/add_article_card.dart b/packages/frontend/lib/blog/widgets/add_article_card.dart new file mode 100644 index 0000000..7b73d31 --- /dev/null +++ b/packages/frontend/lib/blog/widgets/add_article_card.dart @@ -0,0 +1,154 @@ +import 'dart:math' as math; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/data/providers/auth_provider.dart'; +import 'package:frontend/main.dart'; +import 'package:frontend/utils/misc.dart'; +import 'package:provider/provider.dart'; + +class AddArticleCard extends StatelessWidget { + const AddArticleCard({super.key}); + + @override + Widget build(BuildContext context) { + return const DottedBorder(); + } +} + +class DottedBorder extends StatelessWidget { + final Color color; + final double strokeWidth; + final double gap; + + const DottedBorder({super.key, this.color = Colors.black, this.strokeWidth = 1.5, this.gap = 5.0}); + + @override + Widget build(BuildContext context) { + final typography = FluentTheme.of(context).typography; + final user = context.read().user; + + return Padding( + padding: EdgeInsets.all(strokeWidth / 2), + child: GestureDetector( + onTap: () { + if (user == null) { + router.pushReplacement("/login", extra: {'returnUrl': '/posts/new'}); + return; + } + + router.push('/posts/new'); + }, + child: Container( + alignment: Alignment.center, + width: 300, + height: 250, + child: CustomPaint( + painter: DashRectPainter(color: color, strokeWidth: strokeWidth, gap: gap), + child: SizedBox( + width: 200, + height: 200, + child: Card( + borderRadius: const BorderRadius.all(Radius.circular(5)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(FluentIcons.page_add, size: 34), + const SizedBox(height: 16), + Text( + "New Post +", + style: typography.bodyStrong!.copyWith(color: blogColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + )), + ), + ), + ); + } +} + +class DashRectPainter extends CustomPainter { + double strokeWidth; + Color color; + double gap; + + DashRectPainter({this.strokeWidth = 5.0, this.color = Colors.black, this.gap = 5.0}); + + @override + void paint(Canvas canvas, Size size) { + Paint dashedPaint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke; + + double x = size.width; + double y = size.height; + + Path topPath = getDashedPath( + a: const math.Point(0, 0), + b: math.Point(x, 0), + gap: gap, + ); + + Path rightPath = getDashedPath( + a: math.Point(x, 0), + b: math.Point(x, y), + gap: gap, + ); + + Path bottomPath = getDashedPath( + a: math.Point(0, y), + b: math.Point(x, y), + gap: gap, + ); + + Path leftPath = getDashedPath( + a: const math.Point(0, 0), + b: math.Point(0.001, y), + gap: gap, + ); + + canvas.drawPath(topPath, dashedPaint); + canvas.drawPath(rightPath, dashedPaint); + canvas.drawPath(bottomPath, dashedPaint); + canvas.drawPath(leftPath, dashedPaint); + } + + Path getDashedPath({ + required math.Point a, + required math.Point b, + required gap, + }) { + Size size = Size(b.x - a.x, b.y - a.y); + Path path = Path(); + path.moveTo(a.x, a.y); + bool shouldDraw = true; + math.Point currentPoint = math.Point(a.x, a.y); + + num radians = math.atan(size.height / size.width); + + num dx = math.cos(radians) * gap < 0 ? math.cos(radians) * gap * -1 : math.cos(radians) * gap; + + num dy = math.sin(radians) * gap < 0 ? math.sin(radians) * gap * -1 : math.sin(radians) * gap; + + while (currentPoint.x <= b.x && currentPoint.y <= b.y) { + shouldDraw + ? path.lineTo(double.parse(currentPoint.x.toString()), double.parse(currentPoint.y.toString())) + : path.moveTo(double.parse(currentPoint.x.toString()), double.parse(currentPoint.y.toString())); + shouldDraw = !shouldDraw; + currentPoint = math.Point( + currentPoint.x + dx, + currentPoint.y + dy, + ); + } + return path; + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } +} diff --git a/packages/frontend/lib/blog/widgets/article_base_layout.dart b/packages/frontend/lib/blog/widgets/article_base_layout.dart new file mode 100644 index 0000000..d4bf789 --- /dev/null +++ b/packages/frontend/lib/blog/widgets/article_base_layout.dart @@ -0,0 +1,92 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/blog/blog.dart'; +import 'package:frontend/data/api_service.dart'; +import 'package:frontend/main.dart'; + +import 'package:frontend/utils/provider.dart'; +import 'package:shared/models.dart'; + +import '../../utils/misc.dart'; + +class ArticleBaseLayout extends StatefulWidget { + final int? articleId; + + final Widget Function(ArticleDetailLoader detailProv, ArticleBaseLayoutState layout) child; + + const ArticleBaseLayout({this.articleId, super.key, required this.child}); + + @override + State createState() => ArticleBaseLayoutState(); +} + +class ArticleBaseLayoutState extends State { + final _detailProvider = ArticleDetailLoader(); + + bool _showingLoading = false; + + @override + void initState() { + super.initState(); + + final id = widget.articleId; + if (id != null) _detailProvider.fetchArticle(id); + } + + @override + Widget build(BuildContext context) { + return WebConstrainedLayout( + child: StreamBuilder>( + stream: _detailProvider.stream, + initialData: _detailProvider.lastEvent, + builder: (_, __) => ScaffoldPage.scrollable( + children: [ + if (_showingLoading) const SizedBox(width: double.maxFinite, child: ProgressBar()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40.0, vertical: 30), + child: widget.child(_detailProvider, this), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + super.dispose(); + _detailProvider.dispose(); + } + + void setLoading(bool show) => setState(() => _showingLoading = show); + + void handleErrors(ProviderEvent
event) { + if (event.state != ProviderState.error) return; + showError(context, event.errorMessage!); + } +} + +class ArticleDetailLoader extends BaseProvider
{ + ApiService get apiSvc => getIt.get(); + + Article? get article => lastEvent?.data; + + User? _user; + + User? get owner => _user; + + Future fetchArticle(int articleId) async { + final result = await safeRun(() => apiSvc.getArticle(articleId)); + if (result == null) return; + + addEvent(ProviderEvent.success(data: result)); + + await _fetchOwner(result.ownerId); + } + + Future _fetchOwner(int ownerId) async { + try { + _user = await apiSvc.getUserById(ownerId); + addEvent(ProviderEvent.success(data: article!)); + } catch (_) {} + } +} diff --git a/packages/frontend/lib/blog/widgets/article_card.dart b/packages/frontend/lib/blog/widgets/article_card.dart new file mode 100644 index 0000000..fd99313 --- /dev/null +++ b/packages/frontend/lib/blog/widgets/article_card.dart @@ -0,0 +1,51 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/main.dart'; +import 'package:frontend/utils/misc.dart'; +import 'package:shared/models.dart'; + +class ArticleCard extends StatelessWidget { + final Article article; + + const ArticleCard(this.article, {super.key}); + + @override + Widget build(BuildContext context) { + final typography = FluentTheme.of(context).typography; + return GestureDetector( + onTap: () => router.push('/posts/${article.id}'), + child: SizedBox( + width: 300, + height: 250, + child: Card( + borderColor: Colors.grey.withOpacity(0.3), + borderRadius: BorderRadius.zero, + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: article.imageUrl != null + ? imageView(article.imageUrl!) + : Container(color: blogColor.withOpacity(0.1)), + ), + const SizedBox(height: 16), + Text( + article.title, + style: typography.bodyStrong!.copyWith(color: blogColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + article.description, + style: typography.bodyLarge!.copyWith(color: blogColor, fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + ], + ), + ), + ), + ); + } +} diff --git a/packages/frontend/lib/blog/widgets/article_form.dart b/packages/frontend/lib/blog/widgets/article_form.dart new file mode 100644 index 0000000..4dfaad2 --- /dev/null +++ b/packages/frontend/lib/blog/widgets/article_form.dart @@ -0,0 +1,166 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/blog/widgets/article_base_layout.dart'; +import 'package:frontend/data/providers/article_provider.dart'; +import 'package:frontend/main.dart'; +import 'package:frontend/utils/misc.dart'; +import 'package:frontend/utils/provider.dart'; +import 'package:provider/provider.dart'; + +const _spacing = SizedBox(height: 24); + +class ArticleFormView extends StatefulWidget { + final String? articleId; + const ArticleFormView({super.key, this.articleId}); + + @override + State createState() => _ArticleFormViewState(); +} + +class _ArticleFormViewState extends State { + final _titleCtrl = TextEditingController(); + final _imageUrlCtrl = TextEditingController(); + + bool hasSetDefaults = false; + + int? articleId; + + EditorState? editorState; + + bool get isValidPost => _titleCtrl.text.trim().isNotEmpty && editorState != null && !editorState!.document.isEmpty; + + @override + void initState() { + super.initState(); + + if (widget.articleId != null) { + articleId = int.tryParse(widget.articleId!); + if (articleId == null) router.pop(); + } + if (widget.articleId == null) editorState = EditorState.blank(); + } + + @override + Widget build(BuildContext context) { + final typography = FluentTheme.of(context).typography; + + return ArticleBaseLayout( + articleId: articleId, + child: (detailProv, layout) { + final articleProv = context.read(); + final maybeArticle = detailProv.article; + + if (articleId != null && maybeArticle == null) { + if (detailProv.isLoading) return loadingView(); + if (detailProv.hasError) { + layout.handleErrors(ProviderEvent.error(errorMessage: detailProv.errorMessage!)); + router.pop(); + return const SizedBox.shrink(); + } + } + + if (maybeArticle != null && !hasSetDefaults) { + _titleCtrl.text = maybeArticle.title; + editorState = EditorState(document: markdownToDocument(maybeArticle.description)); + final imageUrl = maybeArticle.imageUrl; + if (imageUrl != null) _imageUrlCtrl.text = imageUrl; + } + + createOrUpdateAction() async { + if (!isValidPost) { + layout.handleErrors(const ProviderEvent.error(errorMessage: 'Post is not valid')); + return; + } + + final title = _titleCtrl.text.trim(); + final description = documentToMarkdown(editorState!.document); + final imageUrl = Uri.tryParse(_imageUrlCtrl.text)?.toString(); + + layout.setLoading(true); + + if (maybeArticle != null) { + await articleProv.updateArticle(maybeArticle.id, title, description, imageUrl); + } else if (widget.articleId == null) { + await articleProv.addArticle(title, description, imageUrl); + } + + layout.setLoading(false); + + if (articleProv.hasError) { + layout.handleErrors(ProviderEvent.error(errorMessage: articleProv.errorMessage!)); + return; + } + + router.pushReplacement('/'); + } + + if (editorState == null) return loadingView(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageHeader( + title: Text(maybeArticle != null ? 'Update Post' : 'New Post'), + padding: 0, + ), + _spacing, + InfoLabel( + label: 'Title', + labelStyle: const TextStyle(fontWeight: FontWeight.w300), + child: TextBox( + controller: _titleCtrl, + keyboardType: TextInputType.text, + autofocus: true, + placeholder: 'Post Title', + style: typography.bodyLarge!.copyWith(color: blogColor), + ), + ), + const SizedBox(height: 32), + InfoLabel(label: 'Write your post'), + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints(maxHeight: 400), + width: double.maxFinite, + child: Card( + borderColor: blogColor.withOpacity(0.1), + borderRadius: BorderRadius.zero, + child: AppFlowyEditor( + shrinkWrap: true, + editorStyle: EditorStyle.desktop( + padding: EdgeInsets.zero, selectionColor: Colors.grey.withOpacity(0.5), cursorColor: blogColor), + editorState: editorState!, + ), + ), + ), + _spacing, + InfoLabel( + label: 'Image Url (Optional)', + labelStyle: const TextStyle(fontWeight: FontWeight.w300), + child: TextBox(controller: _imageUrlCtrl, keyboardType: TextInputType.url), + ), + const SizedBox(height: 28), + Row( + children: [ + const Expanded(child: SizedBox.shrink()), + FilledButton( + style: ButtonStyle( + shape: WidgetStateProperty.all(const RoundedRectangleBorder(borderRadius: BorderRadius.zero))), + onPressed: createOrUpdateAction, + child: Text(widget.articleId == null ? 'Add Post' : 'Update Post'), + ) + ], + ) + ], + ); + }, + ); + } + + @override + void dispose() { + super.dispose(); + _titleCtrl.dispose(); + _imageUrlCtrl.dispose(); + } +} diff --git a/packages/frontend/lib/blog/widgets/article_items.dart b/packages/frontend/lib/blog/widgets/article_items.dart new file mode 100644 index 0000000..e9245db --- /dev/null +++ b/packages/frontend/lib/blog/widgets/article_items.dart @@ -0,0 +1,41 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:frontend/blog/widgets/add_article_card.dart'; +import 'package:frontend/data/providers/article_provider.dart'; +import 'package:frontend/utils/misc.dart'; +import 'package:frontend/utils/provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared/models.dart'; + +import 'article_card.dart'; + +class BlogArticlesWidget extends StatelessWidget { + const BlogArticlesWidget({super.key}); + + @override + Widget build(BuildContext context) { + const spacing = 16.0; + final articleProv = context.read(); + + return StreamBuilder>>( + stream: articleProv.stream, + initialData: articleProv.lastEvent, + builder: (context, snapshot) { + final articles = snapshot.data?.data ?? []; + final state = snapshot.data?.state; + final loading = state == ProviderState.loading; + + if (articles.isEmpty) { + if (loading) return loadingView(); + if (state == ProviderState.error) return errorView(); + } + + return Wrap( + runSpacing: spacing, + spacing: spacing, + alignment: WrapAlignment.start, + children: [const AddArticleCard(), ...articles.map((e) => ArticleCard(e))], + ); + }, + ); + } +} diff --git a/packages/frontend/lib/data/api_service.dart b/packages/frontend/lib/data/api_service.dart new file mode 100644 index 0000000..8d8c3b6 --- /dev/null +++ b/packages/frontend/lib/data/api_service.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +// ignore: avoid_web_libraries_in_flutter +import 'dart:html' as html; + +import 'package:http/http.dart' show Response; +import 'package:http/browser_client.dart'; +import 'package:shared/models.dart'; + +class ApiException extends HttpException { + final Iterable errors; + ApiException(this.errors) : super(errors.join('\n')); +} + +typedef HttpResponseCb = Future Function(); + +class ApiService { + final Uri baseUrl; + final BrowserClient client; + + ApiService(this.baseUrl) : client = BrowserClient()..withCredentials = true; + + bool get hasAuthCookie { + final last = html.document.cookie?.split('auth=').last.trim(); + return last != null && last.length > 10; + } + + Map get _headers => {HttpHeaders.contentTypeHeader: 'application/json'}; + + Uri getUri(String path) => baseUrl.replace(path: '/api$path'); + + void clearAuthCookie() => html.document.cookie = 'auth=' ''; + + Future getUser() async { + final result = await _runCatching(() => client.get(getUri('/users/me'), headers: _headers)); + + final data = jsonDecode(result.body)['user']; + return User.fromJson(data); + } + + Future getUserById(int userId) async { + final result = await _runCatching(() => client.get(getUri('/users/$userId'), headers: _headers)); + + final data = jsonDecode(result.body)['user']; + return User.fromJson(data); + } + + Future loginUser(String email, String password) async { + final requestBody = jsonEncode({'email': email, 'password': password}); + final result = await _runCatching(() => client.post(getUri('/auth/login'), headers: _headers, body: requestBody)); + + final data = jsonDecode(result.body)['user']; + return User.fromJson(data); + } + + Future registerUser(String displayName, String email, String password) async { + final requestBody = jsonEncode({'name': displayName, 'email': email, 'password': password}); + await _runCatching(() => client.post(getUri('/auth/register'), headers: _headers, body: requestBody)); + + return true; + } + + Future> getArticles() async { + final result = await _runCatching(() => client.get(getUri('/articles'), headers: _headers)); + + final items = jsonDecode(result.body)['articles'] as Iterable; + return items.map((e) => Article.fromJson(e)).toList(); + } + + Future
getArticle(int articleId) async { + final result = await _runCatching(() => client.get(getUri('/articles/$articleId'), headers: _headers)); + + final data = jsonDecode(result.body)['article']; + return Article.fromJson(data); + } + + Future
createArticle(String title, String description, String? imageUrl) async { + final dataMap = { + 'title': title, + 'description': description, + if (imageUrl != null && imageUrl.trim().isNotEmpty) 'imageUrl': imageUrl, + }; + + final result = + await _runCatching(() => client.post(getUri('/articles'), headers: _headers, body: jsonEncode(dataMap))); + + final data = jsonDecode(result.body)['article']; + return Article.fromJson(data); + } + + Future
updateArticle(int articleId, String title, String description, String? imageUrl) async { + final requestData = { + 'title': title, + 'description': description, + if (imageUrl != null && imageUrl.trim().isNotEmpty) 'imageUrl': imageUrl, + }; + + final result = await _runCatching( + () => client.put(getUri('/articles/$articleId'), headers: _headers, body: jsonEncode(requestData))); + + final data = jsonDecode(result.body)['article']; + return Article.fromJson(data); + } + + Future deleteArticle(int articleId) async { + await _runCatching(() => client.delete(getUri('/articles/$articleId'), headers: _headers)); + } + + Future _runCatching(HttpResponseCb apiCall) async { + try { + final response = await apiCall.call(); + if (response.statusCode == HttpStatus.ok) return response; + final errors = jsonDecode(response.body)['errors'] as List; + throw ApiException(errors.map((e) => e.toString())); + } on ApiException { + rethrow; + } catch (e) { + throw ApiException([e.toString()]); + } + } +} diff --git a/packages/frontend/lib/data/providers/article_provider.dart b/packages/frontend/lib/data/providers/article_provider.dart new file mode 100644 index 0000000..64aa828 --- /dev/null +++ b/packages/frontend/lib/data/providers/article_provider.dart @@ -0,0 +1,46 @@ +import 'package:frontend/data/api_service.dart'; +import 'package:frontend/main.dart'; +import 'package:frontend/utils/provider.dart'; +import 'package:meta/meta.dart'; +import 'package:shared/models.dart'; + +class ArticleProvider extends BaseProvider> { + @visibleForTesting + ApiService get apiSvc => getIt.get(); + + Future fetchArticles() async { + final articles = await safeRun(() => apiSvc.getArticles()); + if (articles == null) return; + + addEvent(ProviderEvent.success(data: articles)); + } + + Future addArticle(String title, String description, String? imageUrl) async { + final articles = lastEvent?.data ?? []; + final article = await safeRun(() => apiSvc.createArticle(title, description, imageUrl)); + if (article == null) return; + + addEvent(ProviderEvent.success(data: [...articles, article])); + } + + Future updateArticle(int articleId, String title, String description, String? imageUrl) async { + final articles = lastEvent?.data ?? []; + final article = await safeRun(() => apiSvc.updateArticle(articleId, title, description, imageUrl)); + if (article == null) return; + + addEvent(ProviderEvent.success(data: [...articles, article])); + } + + Future deleteArticle( + int articleId, + ) async { + final articles = lastEvent?.data ?? []; + await safeRun(() => apiSvc.deleteArticle( + articleId, + )); + + addEvent(ProviderEvent.success(data: [ + ...articles, + ])); + } +} diff --git a/packages/frontend/lib/data/providers/auth_provider.dart b/packages/frontend/lib/data/providers/auth_provider.dart new file mode 100644 index 0000000..d3c59fa --- /dev/null +++ b/packages/frontend/lib/data/providers/auth_provider.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:frontend/data/api_service.dart'; +import 'package:frontend/main.dart'; +import 'package:frontend/utils/provider.dart'; +import 'package:localstorage/localstorage.dart'; +import 'package:meta/meta.dart'; +import 'package:shared/models.dart'; + +class AuthProvider extends BaseProvider { + @visibleForTesting + ApiService get apiSvc => getIt.get(); + + final LocalStorage _userLocalStore = LocalStorage('user_session_store'); + static const String userStorageKey = 'user_data'; + + User? get user { + final userFromState = lastEvent?.data; + if (userFromState != null) return userFromState; + final serializedUser = _userLocalStore.getItem(userStorageKey); + return serializedUser == null ? null : User.fromJson(serializedUser); + } + + Future getUser() async { + if (!apiSvc.hasAuthCookie) return; + + final user = await safeRun(() => apiSvc.getUser()); + if (user == null) return; + + _setUser(user); + } + + Future login(String email, String password) async { + final user = await safeRun(() => apiSvc.loginUser(email, password)); + if (user == null) return; + + _setUser(user); + } + + Future register(String displayName, String email, String password) async { + final success = await safeRun(() => apiSvc.registerUser(displayName, email, password)); + if (success != true) return false; + + addEvent(const ProviderEvent.idle()); + return true; + } + + void _setUser(User user) { + addEvent(ProviderEvent.success(data: user)); + + _userLocalStore.setItem(userStorageKey, user.toJson()); + } + + void logout() { + apiSvc.clearAuthCookie(); + _userLocalStore.clear(); + addEvent(const ProviderEvent.idle()); + } +} diff --git a/packages/frontend/lib/main.dart b/packages/frontend/lib/main.dart new file mode 100644 index 0000000..2cf5107 --- /dev/null +++ b/packages/frontend/lib/main.dart @@ -0,0 +1,121 @@ +import 'package:fast_cached_network_image/fast_cached_network_image.dart'; +import 'package:fluent_ui/fluent_ui.dart' hide Colors; +import 'package:flutter/material.dart' show Colors; +import 'package:frontend/blog/widgets/article_form.dart'; +import 'package:frontend/blog/blog_detail.dart'; +import 'package:frontend/blog/blog.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:shared/shared.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'auth/auth.dart'; +import 'data/api_service.dart'; +import 'utils/misc.dart'; + +import 'data/providers/auth_provider.dart'; +import 'data/providers/article_provider.dart'; + +final router = GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, __) => const BlogPage()), + GoRoute( + path: '/login', + builder: (_, state) { + final extra = state.extra; + return LoginPage(returnUrl: extra is Map ? extra['returnUrl'] : null); + }, + ), + GoRoute(path: '/register', builder: (_, __) => const RegisterPage(), name: 'register'), + GoRoute(path: '/posts/new', builder: (_, __) => const ArticleFormView()), + GoRoute(path: '/posts/:postId', builder: (_, state) => BlogDetail(state.pathParameters['postId'] ?? '')), + GoRoute( + path: '/posts/:postId/edit', + builder: (_, state) => ArticleFormView(articleId: state.pathParameters['postId'] ?? '')), + ], +); + +final getIt = GetIt.instance; + +void main() async { + getIt.registerSingleton(ApiService(appEnv.apiURL)); + + await FastCachedImageConfig.init(clearCacheAfter: const Duration(hours: 1)); + + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProvider(create: (_) => ArticleProvider()), + ], + child: FluentApp.router( + routerConfig: router, + title: 'Dart Blog', + debugShowCheckedModeBanner: false, + color: Colors.red, + theme: FluentThemeData.light(), + builder: (_, child) => _AppLayout(child: child ?? const SizedBox.shrink()), + ), + ); + } +} + +class _AppLayout extends StatelessWidget { + final Widget child; + + const _AppLayout({required this.child}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FluentTheme( + data: FluentThemeData.dark(), + child: Container( + decoration: const BoxDecoration(color: Colors.black87), + padding: const EdgeInsets.only(top: 12), + alignment: Alignment.center, + child: PageHeader( + title: GestureDetector( + onTap: () => router.pushReplacement('/'), + child: Row( + children: [ + const Text('Dart Blog', style: TextStyle(fontSize: 20)), + const SizedBox(width: 10), + Image.asset('web/icons/icon-192.png', width: 24, height: 24), + ], + ), + ), + commandBar: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const AuthHeaderOptions(), + const SizedBox(width: 24), + Tooltip( + message: 'View on Github', + displayHorizontally: true, + useMousePosition: false, + style: const TooltipThemeData(preferBelow: true), + child: IconButton( + icon: const Icon(FluentIcons.graph_symbol, size: 24.0), + onPressed: () => launchUrl(Uri.parse(projectUrl)), + ), + ), + ], + ), + ), + ), + ), + Expanded(child: child), + ], + ); + } +} diff --git a/packages/frontend/lib/utils/misc.dart b/packages/frontend/lib/utils/misc.dart new file mode 100644 index 0000000..d2daacf --- /dev/null +++ b/packages/frontend/lib/utils/misc.dart @@ -0,0 +1,66 @@ +import 'package:cherry_toast/cherry_toast.dart'; +import 'package:cherry_toast/resources/arrays.dart'; +import 'package:fast_cached_network_image/fast_cached_network_image.dart'; +import 'package:fluent_ui/fluent_ui.dart'; + +const projectUrl = 'https://github.com/codekeyz/dart-blog'; + +const blogColor = Color(0xff1c2834); + +void showError(BuildContext context, String error) => CherryToast.error( + title: Text( + error, + style: FluentTheme.of(context).typography.bodyLarge!.copyWith(fontSize: 12, color: Colors.white), + ), + toastPosition: Position.bottom, + autoDismiss: true, + borderRadius: 0, + backgroundColor: blogColor, + shadowColor: Colors.transparent, + toastDuration: const Duration(seconds: 5), + displayCloseButton: false, + iconWidget: const Icon(FluentIcons.error, color: Colors.white), + ).show(context); + +const acrylicBackground = Card( + padding: EdgeInsets.zero, + child: SizedBox( + height: double.maxFinite, + width: double.maxFinite, + child: Acrylic(tint: blogColor), + ), +); + +loadingView({String message = 'loading, please wait...', bool showMessage = true, double? size}) => Container( + alignment: Alignment.center, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + SizedBox(width: size, height: size, child: const ProgressRing(strokeWidth: 2)), + if (showMessage) ...[ + const SizedBox(height: 24), + Text(message, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w300)), + ] + ])); + +errorView({String? message}) { + message ??= 'Oops!, an error occurred'; + return Container( + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(FluentIcons.error), + const SizedBox(height: 24), + Text(message, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w300)), + ], + ), + ); +} + +imageView(String imageUrl, {double? width, double? height}) => FastCachedImage( + url: imageUrl, + fit: BoxFit.cover, + height: height ?? double.maxFinite, + width: width ?? double.maxFinite, + fadeInDuration: const Duration(seconds: 1), + loadingBuilder: (p0, p1) => loadingView(showMessage: false, size: 24), + ); diff --git a/packages/frontend/lib/utils/provider.dart b/packages/frontend/lib/utils/provider.dart new file mode 100644 index 0000000..48390cb --- /dev/null +++ b/packages/frontend/lib/utils/provider.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +import '../data/api_service.dart'; + +enum ProviderState { idle, loading, success, error } + +class ProviderEvent { + final T? data; + final ProviderState state; + final String? errorMessage; + + const ProviderEvent.idle() + : state = ProviderState.idle, + data = null, + errorMessage = null; + + const ProviderEvent.loading({this.data}) + : state = ProviderState.loading, + errorMessage = null; + + const ProviderEvent.success({required this.data}) + : state = ProviderState.success, + errorMessage = null; + + const ProviderEvent.error({required this.errorMessage}) + : state = ProviderState.error, + data = null; +} + +mixin DataStreamMixin { + final _streamController = StreamController.broadcast(); + + /// access the stream + Stream get stream => _streamController.stream; + + /// access the sink + @protected + Sink get sink => _streamController.sink; + + /// + T? _lastEvent; + + /// access the last event sent into the stream + T? get lastEvent => _lastEvent; + + /// adds an event into the stream + /// also stores is as a [lastEvent] + /// and notifies state + @protected + void addEvent(T event) { + _lastEvent = event; + sink.add(event); + } + + /// clear lastevent + void clear() { + _lastEvent = null; + } + + /// close the stream + void dispose() { + _streamController.close(); + } +} + +abstract class BaseProvider extends ChangeNotifier with DataStreamMixin> { + ProviderState? get state => lastEvent?.state; + + bool get isLoading => state == ProviderState.loading; + + bool get hasError => state == ProviderState.error; + + String? get errorMessage => lastEvent?.errorMessage; + + @override + void clear() { + super.clear(); + addEvent(const ProviderEvent.idle()); + } + + Future safeRun(FutureOr Function() func) async { + addEvent(const ProviderEvent.loading()); + + try { + return await func.call(); + } on ApiException catch (e) { + addEvent(ProviderEvent.error(errorMessage: e.errors.join('\n'))); + return null; + } + } +} diff --git a/packages/frontend/pubspec.lock b/packages/frontend/pubspec.lock new file mode 100644 index 0000000..73d6796 --- /dev/null +++ b/packages/frontend/pubspec.lock @@ -0,0 +1,1350 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + url: "https://pub.dev" + source: hosted + version: "72.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + url: "https://pub.dev" + source: hosted + version: "6.7.0" + appflowy_editor: + dependency: "direct main" + description: + name: appflowy_editor + sha256: "568a9e73315442157fdd0ff892aa5abed418bec0c1bf8885bb352c26d078a57f" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003 + url: "https://pub.dev" + source: hosted + version: "2.2.8" + bidi: + dependency: transitive + description: + name: bidi + sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" + url: "https://pub.dev" + source: hosted + version: "2.0.12" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + url: "https://pub.dev" + source: hosted + version: "8.9.2" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cherry_toast: + dependency: "direct main" + description: + name: cherry_toast + sha256: b2d67085fa1d533f41ef6d079a83b01084a5e508d20c958636a3487f14e6f839 + url: "https://pub.dev" + source: hosted + version: "1.11.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "88b0fddbe4c92910fefc09cc0248f5e7f0cd23e450ded4c28f16ab8ee8f83268" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + url: "https://pub.dev" + source: hosted + version: "2.3.7" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + url: "https://pub.dev" + source: hosted + version: "10.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + dio: + dependency: transitive + description: + name: dio + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + fast_cached_network_image: + dependency: "direct main" + description: + name: fast_cached_network_image + sha256: "91f1d48d10e2916b83a1e7545c1eaf752f85b32acfb1473be1f9fa51d73afef0" + url: "https://pub.dev" + source: hosted + version: "1.2.9" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877 + url: "https://pub.dev" + source: hosted + version: "8.1.3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fluent_ui: + dependency: "direct main" + description: + name: fluent_ui + sha256: e7804bf3bbb3ecf9e77d5498181dc36375f5ca736ccfb3862fea17c50050eb89 + url: "https://pub.dev" + source: hosted + version: "4.9.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_highlight: + dependency: transitive + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" + url: "https://pub.dev" + source: hosted + version: "2.0.23" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "1b7723a814d84fb65869ea7115cdb3ee7c3be5a27a755c1ec60e049f6b9fcbb2" + url: "https://pub.dev" + source: hosted + version: "2.0.11" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: b465e99ce64ba75e61c8c0ce3d87b66d8ac07f0b35d0a7e0263fcfc10f99e836 + url: "https://pub.dev" + source: hosted + version: "13.2.5" + grammer: + dependency: transitive + description: + name: grammer + sha256: "333c0f99fb116ae554276f64769c3a5219d314bb4fc9de43d83ae9746e0b4dd4" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + highlight: + dependency: transitive + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: transitive + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + html: + dependency: transitive + description: + name: html + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + url: "https://pub.dev" + source: hosted + version: "0.15.5" + http: + dependency: "direct main" + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + url: "https://pub.dev" + source: hosted + version: "6.8.0" + keyboard_height_plugin: + dependency: transitive + description: + name: keyboard_height_plugin + sha256: "3a51c8ebb43465ebe0b3bad17f3b6d945421e58011f3f5a08134afe69a3d775f" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + localstorage: + dependency: "direct main" + description: + name: localstorage + sha256: fdff4f717114e992acfd4045dc4a9ab9b987ca57f020965d63e3eb34089c60d8 + url: "https://pub.dev" + source: hosted + version: "4.0.1+4" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + markdown_widget: + dependency: "direct main" + description: + name: markdown_widget + sha256: "216dced98962d7699a265344624bc280489d739654585ee881c95563a3252fac" + url: "https://pub.dev" + source: hosted + version: "2.3.2+6" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + math_expressions: + dependency: transitive + description: + name: math_expressions + sha256: e32d803d758ace61cc6c4bdfed1226ff60a6a23646b35685670d28b5616139f8 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + meta: + dependency: "direct main" + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mysql_client: + dependency: transitive + description: + name: mysql_client + sha256: "6a0fdcbe3e0721c637f97ad24649be2f70dbce2b21ede8f962910e640f753fc2" + url: "https://pub.dev" + source: hosted + version: "0.0.27" + nanoid: + dependency: transitive + description: + name: nanoid + sha256: be3f8752d9046c825df2f3914195151eb876f3ad64b9d833dd0b799b77b8759e + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + numerus: + dependency: transitive + description: + name: numerus + sha256: a17a3f34527497e89378696a76f382b40dc534c4a57b3778de246ebc1ce2ca99 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a + url: "https://pub.dev" + source: hosted + version: "2.2.12" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdf: + dependency: transitive + description: + name: pdf + sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07" + url: "https://pub.dev" + source: hosted + version: "3.11.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + postgres: + dependency: transitive + description: + name: postgres + sha256: c271fb05cf83f47ff8d6915ea7fc780381e581309f55846a21a3257ad6b05f6d + url: "https://pub.dev" + source: hosted + version: "3.4.1" + provider: + dependency: "direct main" + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + sasl_scram: + dependency: transitive + description: + name: sasl_scram + sha256: a47207a436eb650f8fdcf54a2e2587b850dc3caef9973ce01f332b07a6fc9cb9 + url: "https://pub.dev" + source: hosted + version: "0.1.1" + saslprep: + dependency: transitive + description: + name: saslprep + sha256: "3d421d10be9513bf4459c17c5e70e7b8bc718c9fc5ad4ba5eb4f5fd27396f740" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + scroll_pos: + dependency: transitive + description: + name: scroll_pos + sha256: cebf602b2dd939de6832bb902ffefb574608d1b84f420b82b381a4007d3c1e1b + url: "https://pub.dev" + source: hosted + version: "0.5.0" + scroll_to_index: + dependency: transitive + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + shared: + dependency: "direct main" + description: + path: "../shared" + relative: true + source: path + version: "1.0.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + url: "https://pub.dev" + source: hosted + version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "4468b24876d673418a7b7147e5a08a715b4998a7ae69227acafaab762e0e5490" + url: "https://pub.dev" + source: hosted + version: "2.5.4+5" + sqflite_common_ffi: + dependency: transitive + description: + name: sqflite_common_ffi + sha256: d316908f1537725427ff2827a5c5f3b2c1bc311caed985fe3c9b10939c9e11ca + url: "https://pub.dev" + source: hosted + version: "2.3.4" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: bb174b3ec2527f9c5f680f73a89af8149dd99782fbb56ea88ad0807c5638f2ed + url: "https://pub.dev" + source: hosted + version: "2.4.7" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + stemmer: + dependency: transitive + description: + name: stemmer + sha256: "9a548a410ad690152b7de946c45e8b166f157f2811fb3ad717da3721f5cee144" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + string_validator: + dependency: transitive + description: + name: string_validator + sha256: a278d038104aa2df15d0e09c47cb39a49f907260732067d0034dc2f2e4e2ac94 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" + url: "https://pub.dev" + source: hosted + version: "1.25.8" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + test_core: + dependency: transitive + description: + name: test_core + sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" + url: "https://pub.dev" + source: hosted + version: "0.6.5" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: "054cedf68706bb142839ba0ae6b135f6b68039f0b8301cbe8784ae653d5ff8de" + url: "https://pub.dev" + source: hosted + version: "3.7.0" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_html: + dependency: transitive + description: + name: universal_html + sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "23d8bf65605401a6a32cff99435fed66ef3dab3ddcad3454059165df46496a3b" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "0b9149c6ddb013818075b072b9ddc1b89a5122fff1275d4648d297086b46c4f0" + url: "https://pub.dev" + source: hosted + version: "1.1.12" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" + url: "https://pub.dev" + source: hosted + version: "1.1.12" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: f3b9b6e4591c11394d4be4806c63e72d3a41778547b2c1e2a8a04fadcfd7d173 + url: "https://pub.dev" + source: hosted + version: "1.1.12" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "84ba388638ed7a8cb3445a320c8273136ab2631cd5f2c57888335504ddab1bc2" + url: "https://pub.dev" + source: hosted + version: "5.8.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + yaroorm: + dependency: transitive + description: + name: yaroorm + sha256: "72bdeab359fe63e11e90dbe5eb355ef6655a0777bf376e3c7d0e3ab1fc5d6fe6" + url: "https://pub.dev" + source: hosted + version: "0.0.3" +sdks: + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/packages/frontend/pubspec.yaml b/packages/frontend/pubspec.yaml new file mode 100644 index 0000000..7f2a470 --- /dev/null +++ b/packages/frontend/pubspec.yaml @@ -0,0 +1,41 @@ +name: frontend +description: "A new Flutter project." +publish_to: "none" + +version: 1.0.0+1 + +environment: + sdk: ^3.2.0 + +dependencies: + flutter: + sdk: flutter + go_router: ^13.0.1 + fluent_ui: ^4.8.3 + url_launcher: + cherry_toast: ^1.6.4 + + http: + json_annotation: ^4.8.1 + provider: ^6.1.1 + meta: + get_it: ^7.6.4 + collection: + timeago: ^3.6.0 + fast_cached_network_image: ^1.2.0 + markdown_widget: + appflowy_editor: + localstorage: ^4.0.1+4 + shared: + path: '../shared' + +dev_dependencies: + flutter_lints: ^3.0.1 + json_serializable: ^6.7.1 + build_runner: ^2.4.7 + +flutter: + uses-material-design: true + + assets: + - web/icons/ diff --git a/packages/frontend/web/favicon.ico b/packages/frontend/web/favicon.ico new file mode 100644 index 0000000..ec3421c Binary files /dev/null and b/packages/frontend/web/favicon.ico differ diff --git a/packages/frontend/web/favicon.png b/packages/frontend/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/packages/frontend/web/favicon.png differ diff --git a/packages/frontend/web/icons/Icon-maskable-192.png b/packages/frontend/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..f485e2f Binary files /dev/null and b/packages/frontend/web/icons/Icon-maskable-192.png differ diff --git a/packages/frontend/web/icons/Icon-maskable-512.png b/packages/frontend/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..303f468 Binary files /dev/null and b/packages/frontend/web/icons/Icon-maskable-512.png differ diff --git a/packages/frontend/web/icons/icon-192.png b/packages/frontend/web/icons/icon-192.png new file mode 100644 index 0000000..f485e2f Binary files /dev/null and b/packages/frontend/web/icons/icon-192.png differ diff --git a/packages/frontend/web/icons/icon-512.png b/packages/frontend/web/icons/icon-512.png new file mode 100644 index 0000000..303f468 Binary files /dev/null and b/packages/frontend/web/icons/icon-512.png differ diff --git a/packages/frontend/web/index.html b/packages/frontend/web/index.html new file mode 100644 index 0000000..a559ff1 --- /dev/null +++ b/packages/frontend/web/index.html @@ -0,0 +1,79 @@ + + + + + + + + + + + Dart Blog + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/web/manifest.json b/packages/frontend/web/manifest.json new file mode 100644 index 0000000..d9f63dd --- /dev/null +++ b/packages/frontend/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Dart Blog", + "short_name": "dart-blog", + "start_url": ".", + "display": "standalone", + "background_color": "#1c2834", + "theme_color": "#1c2834", + "description": "Full-stack blog show-casing Dart on the Backend and Flutter Web on the web.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/shared/.DS_Store b/packages/shared/.DS_Store new file mode 100644 index 0000000..84903ab Binary files /dev/null and b/packages/shared/.DS_Store differ diff --git a/packages/shared/.gitignore b/packages/shared/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/shared/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/shared/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/shared/README.md b/packages/shared/README.md new file mode 100644 index 0000000..8831761 --- /dev/null +++ b/packages/shared/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/packages/shared/analysis_options.yaml b/packages/shared/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/shared/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/shared/lib/models.dart b/packages/shared/lib/models.dart new file mode 100644 index 0000000..5321c20 --- /dev/null +++ b/packages/shared/lib/models.dart @@ -0,0 +1,2 @@ +export 'src/models/article.dart'; +export 'src/models/user.dart'; diff --git a/packages/shared/lib/shared.dart b/packages/shared/lib/shared.dart new file mode 100644 index 0000000..bb6c539 --- /dev/null +++ b/packages/shared/lib/shared.dart @@ -0,0 +1,30 @@ +enum AppEnvironment { + local, + staging, + prod; + + const AppEnvironment(); + + Uri get apiURL => switch (this) { + AppEnvironment.prod => Uri.https('blog-backend-369d.globeapp.dev'), + _ => Uri.http('localhost:3000'), + }; + + Uri get frontendURL => switch (this) { + AppEnvironment.prod => Uri.https('blog-frontend.globeapp.dev'), + _ => Uri.http('localhost:60964'), + }; +} + +final isDebugMode = const bool.fromEnvironment("dart.vm.product") == false; + +bool get isTestMode { + var isDebug = false; + assert(() { + isDebug = true; + return true; + }()); + return isDebug; +} + +final appEnv = isTestMode || isDebugMode ? AppEnvironment.local : AppEnvironment.prod; diff --git a/lib/src/models/article.dart b/packages/shared/lib/src/models/article.dart similarity index 69% rename from lib/src/models/article.dart rename to packages/shared/lib/src/models/article.dart index d40b65f..03af761 100644 --- a/lib/src/models/article.dart +++ b/packages/shared/lib/src/models/article.dart @@ -1,14 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:yaroorm/yaroorm.dart'; - -import 'user.dart'; part 'article.g.dart'; -@table @JsonSerializable(fieldRename: FieldRename.snake) -class Article extends Entity
{ - @primaryKey +class Article { final int id; final String title; @@ -16,13 +11,9 @@ class Article extends Entity
{ final String? imageUrl; - @bindTo(User, onDelete: ForeignKeyAction.cascade) final int ownerId; - @createdAtCol final DateTime createdAt; - - @updatedAtCol final DateTime updatedAt; Article( @@ -35,8 +26,6 @@ class Article extends Entity
{ required this.updatedAt, }); - BelongsTo get owner => belongsTo(#owner); - Map toJson() => _$ArticleToJson(this); factory Article.fromJson(Map json) => _$ArticleFromJson(json); diff --git a/lib/src/models/user.dart b/packages/shared/lib/src/models/user.dart similarity index 60% rename from lib/src/models/user.dart rename to packages/shared/lib/src/models/user.dart index 580b2ab..ba3bbbb 100644 --- a/lib/src/models/user.dart +++ b/packages/shared/lib/src/models/user.dart @@ -1,40 +1,27 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:yaroorm/yaroorm.dart'; - -import 'article.dart'; part 'user.g.dart'; -@table @JsonSerializable(fieldRename: FieldRename.snake) -class User extends Entity { - @primaryKey +class User { final int id; final String name; final String email; - @JsonKey(defaultValue: '', includeToJson: false) - final String password; - - @createdAtCol final DateTime createdAt; - @updatedAtCol final DateTime updatedAt; User( this.id, this.name, this.email, { - required this.password, required this.createdAt, required this.updatedAt, }); - HasMany get articles => hasMany
(#articles); - Map toJson() => _$UserToJson(this); factory User.fromJson(Map json) => _$UserFromJson(json); diff --git a/packages/shared/melos_shared.iml b/packages/shared/melos_shared.iml new file mode 100644 index 0000000..389d07a --- /dev/null +++ b/packages/shared/melos_shared.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/shared/pubspec.yaml b/packages/shared/pubspec.yaml new file mode 100644 index 0000000..f429d78 --- /dev/null +++ b/packages/shared/pubspec.yaml @@ -0,0 +1,16 @@ +name: shared +description: A starting point for Dart libraries or applications. +version: 1.0.0 +# repository: https://github.com/my_org/my_repo + +environment: + sdk: ^3.2.0 + +dependencies: + json_annotation: ^4.9.0 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.24.0 + json_serializable: ^6.8.0 + build_runner: ^2.4.13 \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 9d7433a..5caf6ea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -478,16 +478,8 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" - logger: - dependency: "direct main" - description: - name: logger - sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" - url: "https://pub.dev" - source: hosted - version: "2.4.0" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 @@ -586,10 +578,10 @@ packages: dependency: "direct main" description: name: pharaoh - sha256: "6d7f19377feb93ab42f790be32c450a90662a6ed26ae4e10900519cb8549c757" + sha256: b5614cef80b341bac6ffb15407c1eb446eb509a7f2d86ea7b9676ed98208adcc url: "https://pub.dev" source: hosted - version: "0.0.8+1" + version: "0.0.8+2" platform: dependency: transitive description: @@ -626,10 +618,10 @@ packages: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.3" prompts: dependency: transitive description: @@ -710,6 +702,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + shared: + dependency: "direct main" + description: + path: "packages/shared" + relative: true + source: path + version: "1.0.0" shelf: dependency: transitive description: @@ -786,10 +785,10 @@ packages: dependency: transitive description: name: spanner - sha256: "1eee0f4fcb606f09db52b8f5ecd1036f8b6270363c052f98605bc5d964b13ee0" + sha256: "52768be5029fb1d408e2e762cb282214b79b52c4702e1efb144df139ba40d6cd" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" spookie: dependency: "direct dev" description: @@ -1050,18 +1049,18 @@ packages: dependency: "direct main" description: name: yaroorm - sha256: "72bdeab359fe63e11e90dbe5eb355ef6655a0777bf376e3c7d0e3ab1fc5d6fe6" + sha256: d676e8786a2f332d47f7817680176281c31352cdf3d60c96b19613c1edd90812 url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.0.4" yaroorm_cli: dependency: "direct dev" description: path: "packages/yaroorm_cli" ref: HEAD - resolved-ref: "04c58d9468b101e35e20949ecb367daaf5c4166e" + resolved-ref: "04546ad61c86d669c475e11d819a50021ea43355" url: "https://github.com/codekeyz/yaroorm.git" source: git - version: "0.0.2" + version: "0.0.3+1" sdks: dart: ">=3.5.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1f0d005..95b2333 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.0.0 publish_to: none environment: - sdk: ^3.2.2 + sdk: ^3.2.0 dependencies: mime: ^1.0.4 @@ -14,22 +14,23 @@ dependencies: path: ^1.9.0 bcrypt: ^1.1.3 http: ^1.1.2 - pharaoh: ^0.0.8+1 - yaroorm: ^0.0.3 + pharaoh: ^0.0.8+2 + yaroorm: ^0.0.4 collection: ^1.18.0 + json_annotation: ^4.9.0 - logger: ^2.0.2+1 + logging: ^1.3.0 dart_jsonwebtoken: ^2.12.2 - json_annotation: ^4.8.1 - + shared: + path: 'packages/shared' dev_dependencies: lints: ^5.0.0 test: ^1.24.0 - json_serializable: ^6.7.1 melos: ^6.2.0 build_runner: ^2.4.13 spookie: 1.0.2+3 + json_serializable: ^6.8.0 yaroorm_cli: git: url: 'https://github.com/codekeyz/yaroorm.git' diff --git a/pubspec_overrides.yaml b/pubspec_overrides.yaml new file mode 100644 index 0000000..07e18ab --- /dev/null +++ b/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: shared +dependency_overrides: + shared: + path: packages/shared diff --git a/test/backend_test.dart b/test/backend_test.dart index 2bf64d7..96ff3c5 100644 --- a/test/backend_test.dart +++ b/test/backend_test.dart @@ -2,8 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:backend/backend.dart'; -import 'package:backend/src/models/article.dart'; -import 'package:backend/src/models/user.dart'; +import 'package:backend/src/models.dart'; +import 'package:shared/models.dart'; + import 'package:spookie/spookie.dart'; import '../database/database.dart' as database; @@ -93,8 +94,8 @@ void main() { }); test('should error on existing email', () async { - final randomUser = await UserQuery.findOne(); - expect(randomUser, isA()); + final randomUser = await ServerUserQuery.findOne(); + expect(randomUser, isA()); await testAgent .post(path, {'email': randomUser!.email, 'name': 'Foo Bar', 'password': 'moooasdfmdf'}) @@ -126,7 +127,7 @@ void main() { }); test('should error on in-valid credentials', () async { - final randomUser = await UserQuery.findOne(); + final randomUser = await ServerUserQuery.findOne(); expect(randomUser, isA()); final email = randomUser!.email; @@ -149,7 +150,7 @@ void main() { }); test('should success on valid credentials', () async { - final randomUser = await UserQuery.findOne(); + final randomUser = await ServerUserQuery.findOne(); expect(randomUser, isA()); final baseTest = testAgent.post(path, { @@ -168,11 +169,11 @@ void main() { group('', () { String? authCookie; - User? currentUser; + ServerUser? currentUser; setUpAll(() async { - currentUser = await UserQuery.findOne(); - expect(currentUser, isA()); + currentUser = await ServerUserQuery.findOne(); + expect(currentUser, isA()); final result = await testAgent.post('$baseAPIPath/auth/login', { 'email': currentUser!.email, @@ -208,7 +209,7 @@ void main() { }); test('should get user `/users/` without auth', () async { - final randomUser = await UserQuery.findOne(); + final randomUser = await ServerUserQuery.findOne(); expect(randomUser, isA()); await testAgent @@ -320,7 +321,7 @@ void main() { }); test('should update article', () async { - final article = await ArticleQuery.where((article) => article.ownerId(currentUser!.id)).findOne(); + final article = await ServerArticleQuery.where((article) => article.ownerId(currentUser!.id)).findOne(); expect(article, isA
()); expect(article!.title, isNot('Honey')); @@ -353,7 +354,7 @@ void main() { .test(); const fakeId = 234239389239; - final article = await ArticleQuery.findById(fakeId); + final article = await ServerArticleQuery.findById(fakeId); expect(article, isNull); await testAgent @@ -364,7 +365,7 @@ void main() { }); test('should delete article', () async { - final article = await ArticleQuery.findByOwnerId(currentUser!.id); + final article = await ServerArticleQuery.findByOwnerId(currentUser!.id); expect(article, isA
()); await testAgent @@ -373,7 +374,7 @@ void main() { .expectJsonBody({'message': 'Article deleted'}) .test(); - expect(await ArticleQuery.findById(article.id), isNull); + expect(await ServerArticleQuery.findById(article.id), isNull); }); }); @@ -398,7 +399,7 @@ void main() { }); test('should show article without auth', () async { - final article = await ArticleQuery.findByOwnerId(currentUser!.id); + final article = await ServerArticleQuery.findByOwnerId(currentUser!.id); expect(article, isA
()); await testAgent @@ -409,7 +410,7 @@ void main() { }); test('should get Articles without auth', () async { - final articles = await ArticleQuery.findMany(); + final articles = await ServerArticleQuery.findMany(); expect(articles, isNotEmpty); await testAgent