From b96a05ec32ff537426728c4ce3add4d7958fec0c Mon Sep 17 00:00:00 2001 From: Oracle Public Cloud User Date: Wed, 4 Mar 2026 14:51:14 +0000 Subject: [PATCH] Initial commit: CleanPlate shared Dart package Domain models, enums, constants, validators shared between frontend and backend. --- .gitignore | 7 ++ CHANGELOG.md | 3 + README.md | 39 +++++++++++ analysis_options.yaml | 30 ++++++++ example/cleanplate_shared_example.dart | 13 ++++ lib/cleanplate_shared.dart | 26 +++++++ lib/src/constants/api_constants.dart | 47 +++++++++++++ lib/src/constants/app_limits.dart | 29 ++++++++ lib/src/constants/error_codes.dart | 34 +++++++++ lib/src/enums/cuisine_type.dart | 39 +++++++++++ lib/src/enums/diet_type.dart | 29 ++++++++ lib/src/enums/difficulty.dart | 13 ++++ lib/src/enums/meal_type.dart | 17 +++++ lib/src/models/ai_recipe_request.dart | 27 ++++++++ lib/src/models/ai_recipe_request.g.dart | 33 +++++++++ lib/src/models/api_response.dart | 62 +++++++++++++++++ lib/src/models/api_response.g.dart | 71 +++++++++++++++++++ lib/src/models/cooking_step.dart | 27 ++++++++ lib/src/models/cooking_step.g.dart | 28 ++++++++ lib/src/models/dietary_profile.dart | 27 ++++++++ lib/src/models/dietary_profile.g.dart | 41 +++++++++++ lib/src/models/ingredient.dart | 31 +++++++++ lib/src/models/ingredient.g.dart | 32 +++++++++ lib/src/models/nutrition_info.dart | 37 ++++++++++ lib/src/models/nutrition_info.g.dart | 39 +++++++++++ lib/src/models/recipe.dart | 70 +++++++++++++++++++ lib/src/models/recipe.g.dart | 88 ++++++++++++++++++++++++ lib/src/models/review.dart | 33 +++++++++ lib/src/models/review.g.dart | 36 ++++++++++ lib/src/models/user.dart | 29 ++++++++ lib/src/models/user.g.dart | 35 ++++++++++ lib/src/validators/recipe_validator.dart | 37 ++++++++++ pubspec.yaml | 19 +++++ test/cleanplate_shared_test.dart | 46 +++++++++++++ 34 files changed, 1174 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 example/cleanplate_shared_example.dart create mode 100644 lib/cleanplate_shared.dart create mode 100644 lib/src/constants/api_constants.dart create mode 100644 lib/src/constants/app_limits.dart create mode 100644 lib/src/constants/error_codes.dart create mode 100644 lib/src/enums/cuisine_type.dart create mode 100644 lib/src/enums/diet_type.dart create mode 100644 lib/src/enums/difficulty.dart create mode 100644 lib/src/enums/meal_type.dart create mode 100644 lib/src/models/ai_recipe_request.dart create mode 100644 lib/src/models/ai_recipe_request.g.dart create mode 100644 lib/src/models/api_response.dart create mode 100644 lib/src/models/api_response.g.dart create mode 100644 lib/src/models/cooking_step.dart create mode 100644 lib/src/models/cooking_step.g.dart create mode 100644 lib/src/models/dietary_profile.dart create mode 100644 lib/src/models/dietary_profile.g.dart create mode 100644 lib/src/models/ingredient.dart create mode 100644 lib/src/models/ingredient.g.dart create mode 100644 lib/src/models/nutrition_info.dart create mode 100644 lib/src/models/nutrition_info.g.dart create mode 100644 lib/src/models/recipe.dart create mode 100644 lib/src/models/recipe.g.dart create mode 100644 lib/src/models/review.dart create mode 100644 lib/src/models/review.g.dart create mode 100644 lib/src/models/user.dart create mode 100644 lib/src/models/user.g.dart create mode 100644 lib/src/validators/recipe_validator.dart create mode 100644 pubspec.yaml create mode 100644 test/cleanplate_shared_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/.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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8831761 --- /dev/null +++ b/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/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/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/example/cleanplate_shared_example.dart b/example/cleanplate_shared_example.dart new file mode 100644 index 0000000..d1b9339 --- /dev/null +++ b/example/cleanplate_shared_example.dart @@ -0,0 +1,13 @@ +import 'package:cleanplate_shared/cleanplate_shared.dart'; + +void main() { + // Example: validate a recipe title + final titleError = RecipeValidator.validateTitle('My Delicious Pasta'); + print('Title validation: ${titleError ?? "valid"}'); + + // Example: use diet type enum + print('Diet: ${DietType.vegetarian.displayName}'); + + // Example: API constants + print('Login endpoint: ${ApiConstants.authLogin}'); +} diff --git a/lib/cleanplate_shared.dart b/lib/cleanplate_shared.dart new file mode 100644 index 0000000..c54666f --- /dev/null +++ b/lib/cleanplate_shared.dart @@ -0,0 +1,26 @@ +library; + +// Models +export 'src/models/recipe.dart'; +export 'src/models/ingredient.dart'; +export 'src/models/cooking_step.dart'; +export 'src/models/nutrition_info.dart'; +export 'src/models/user.dart'; +export 'src/models/dietary_profile.dart'; +export 'src/models/review.dart'; +export 'src/models/ai_recipe_request.dart'; +export 'src/models/api_response.dart'; + +// Enums +export 'src/enums/diet_type.dart'; +export 'src/enums/difficulty.dart'; +export 'src/enums/meal_type.dart'; +export 'src/enums/cuisine_type.dart'; + +// Constants +export 'src/constants/api_constants.dart'; +export 'src/constants/error_codes.dart'; +export 'src/constants/app_limits.dart'; + +// Validators +export 'src/validators/recipe_validator.dart'; diff --git a/lib/src/constants/api_constants.dart b/lib/src/constants/api_constants.dart new file mode 100644 index 0000000..c71ab56 --- /dev/null +++ b/lib/src/constants/api_constants.dart @@ -0,0 +1,47 @@ +class ApiConstants { + ApiConstants._(); + + static const String apiVersion = 'v1'; + static const String basePath = '/api/$apiVersion'; + + // Auth + static const String authRegister = '$basePath/auth/register'; + static const String authLogin = '$basePath/auth/login'; + static const String authRefresh = '$basePath/auth/refresh'; + static const String authLogout = '$basePath/auth/logout'; + + // Recipes + static const String recipes = '$basePath/recipes'; + static String recipe(String id) => '$basePath/recipes/$id'; + static String recipeNutrition(String id) => '$basePath/recipes/$id/nutrition'; + static String recipeSave(String id) => '$basePath/recipes/$id/save'; + static const String savedRecipes = '$basePath/recipes/saved'; + + // AI + static const String aiGenerate = '$basePath/ai/generate'; + static const String aiAdapt = '$basePath/ai/adapt'; + static const String aiParseIngredients = '$basePath/ai/parse-ingredients'; + static const String aiSuggestions = '$basePath/ai/suggestions'; + + // Search + static const String search = '$basePath/search'; + static const String searchAutocomplete = '$basePath/search/autocomplete'; + + // Reviews + static String recipeReviews(String recipeId) => '$basePath/recipes/$recipeId/reviews'; + + // Media + static const String uploadImage = '$basePath/media/upload/image'; + static const String uploadVideo = '$basePath/media/upload/video'; + + // Users + static const String currentUser = '$basePath/users/me'; + static const String dietaryProfile = '$basePath/users/me/dietary'; + + // Meal Plans + static const String mealPlans = '$basePath/meal-plans'; + static String mealPlanGroceryList(String id) => '$basePath/meal-plans/$id/grocery-list'; + + // Sync + static const String sync = '$basePath/users/me/sync'; +} diff --git a/lib/src/constants/app_limits.dart b/lib/src/constants/app_limits.dart new file mode 100644 index 0000000..37e4099 --- /dev/null +++ b/lib/src/constants/app_limits.dart @@ -0,0 +1,29 @@ +class AppLimits { + AppLimits._(); + + // Free tier + static const int freeMaxSavedRecipes = 20; + static const int freeMaxAiGenerationsPerDay = 5; + + // Premium tier + static const int premiumMaxAiGenerationsPerDay = 25; + static const int premiumMaxHouseholdMembers = 6; + + // Media + static const int maxImageSizeBytes = 10 * 1024 * 1024; // 10MB + static const int maxVideoSizeBytes = 200 * 1024 * 1024; // 200MB + static const int maxVideoDurationSeconds = 180; // 3 minutes + + // Content + static const int maxRecipeTitleLength = 300; + static const int maxReviewBodyLength = 2000; + static const int maxIngredientsPerRecipe = 50; + static const int maxStepsPerRecipe = 30; + + // Pagination + static const int defaultPageSize = 20; + static const int maxPageSize = 100; + + // Reports + static const int autoHideReportThreshold = 3; +} diff --git a/lib/src/constants/error_codes.dart b/lib/src/constants/error_codes.dart new file mode 100644 index 0000000..e5196c4 --- /dev/null +++ b/lib/src/constants/error_codes.dart @@ -0,0 +1,34 @@ +class ErrorCodes { + ErrorCodes._(); + + // Auth + static const String invalidCredentials = 'INVALID_CREDENTIALS'; + static const String emailAlreadyExists = 'EMAIL_ALREADY_EXISTS'; + static const String tokenExpired = 'TOKEN_EXPIRED'; + static const String tokenInvalid = 'TOKEN_INVALID'; + static const String unauthorized = 'UNAUTHORIZED'; + + // Recipes + static const String recipeNotFound = 'RECIPE_NOT_FOUND'; + static const String recipeAlreadySaved = 'RECIPE_ALREADY_SAVED'; + + // AI + static const String aiRateLimitExceeded = 'AI_RATE_LIMIT_EXCEEDED'; + static const String aiGenerationFailed = 'AI_GENERATION_FAILED'; + static const String aiServiceUnavailable = 'AI_SERVICE_UNAVAILABLE'; + + // Reviews + static const String reviewAlreadyExists = 'REVIEW_ALREADY_EXISTS'; + static const String reviewNotFound = 'REVIEW_NOT_FOUND'; + + // Media + static const String fileTooLarge = 'FILE_TOO_LARGE'; + static const String invalidFileType = 'INVALID_FILE_TYPE'; + static const String uploadFailed = 'UPLOAD_FAILED'; + + // General + static const String validationError = 'VALIDATION_ERROR'; + static const String notFound = 'NOT_FOUND'; + static const String internalError = 'INTERNAL_ERROR'; + static const String rateLimited = 'RATE_LIMITED'; +} diff --git a/lib/src/enums/cuisine_type.dart b/lib/src/enums/cuisine_type.dart new file mode 100644 index 0000000..26caceb --- /dev/null +++ b/lib/src/enums/cuisine_type.dart @@ -0,0 +1,39 @@ +enum CuisineType { + american, + italian, + mexican, + chinese, + japanese, + indian, + thai, + french, + greek, + korean, + vietnamese, + mediterranean, + middleEastern, + african, + caribbean, + other; + + String get displayName { + switch (this) { + case CuisineType.american: return 'American'; + case CuisineType.italian: return 'Italian'; + case CuisineType.mexican: return 'Mexican'; + case CuisineType.chinese: return 'Chinese'; + case CuisineType.japanese: return 'Japanese'; + case CuisineType.indian: return 'Indian'; + case CuisineType.thai: return 'Thai'; + case CuisineType.french: return 'French'; + case CuisineType.greek: return 'Greek'; + case CuisineType.korean: return 'Korean'; + case CuisineType.vietnamese: return 'Vietnamese'; + case CuisineType.mediterranean: return 'Mediterranean'; + case CuisineType.middleEastern: return 'Middle Eastern'; + case CuisineType.african: return 'African'; + case CuisineType.caribbean: return 'Caribbean'; + case CuisineType.other: return 'Other'; + } + } +} diff --git a/lib/src/enums/diet_type.dart b/lib/src/enums/diet_type.dart new file mode 100644 index 0000000..7800892 --- /dev/null +++ b/lib/src/enums/diet_type.dart @@ -0,0 +1,29 @@ +enum DietType { + none, + vegetarian, + vegan, + pescatarian, + keto, + paleo, + glutenFree, + dairyFree, + lowCarb, + whole30, + mediterranean; + + String get displayName { + switch (this) { + case DietType.none: return 'No Restriction'; + case DietType.vegetarian: return 'Vegetarian'; + case DietType.vegan: return 'Vegan'; + case DietType.pescatarian: return 'Pescatarian'; + case DietType.keto: return 'Keto'; + case DietType.paleo: return 'Paleo'; + case DietType.glutenFree: return 'Gluten-Free'; + case DietType.dairyFree: return 'Dairy-Free'; + case DietType.lowCarb: return 'Low Carb'; + case DietType.whole30: return 'Whole30'; + case DietType.mediterranean: return 'Mediterranean'; + } + } +} diff --git a/lib/src/enums/difficulty.dart b/lib/src/enums/difficulty.dart new file mode 100644 index 0000000..7a03e7b --- /dev/null +++ b/lib/src/enums/difficulty.dart @@ -0,0 +1,13 @@ +enum Difficulty { + easy, + medium, + hard; + + String get displayName { + switch (this) { + case Difficulty.easy: return 'Easy'; + case Difficulty.medium: return 'Medium'; + case Difficulty.hard: return 'Hard'; + } + } +} diff --git a/lib/src/enums/meal_type.dart b/lib/src/enums/meal_type.dart new file mode 100644 index 0000000..1f39321 --- /dev/null +++ b/lib/src/enums/meal_type.dart @@ -0,0 +1,17 @@ +enum MealType { + breakfast, + lunch, + dinner, + snack, + dessert; + + String get displayName { + switch (this) { + case MealType.breakfast: return 'Breakfast'; + case MealType.lunch: return 'Lunch'; + case MealType.dinner: return 'Dinner'; + case MealType.snack: return 'Snack'; + case MealType.dessert: return 'Dessert'; + } + } +} diff --git a/lib/src/models/ai_recipe_request.dart b/lib/src/models/ai_recipe_request.dart new file mode 100644 index 0000000..17ff014 --- /dev/null +++ b/lib/src/models/ai_recipe_request.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'ai_recipe_request.g.dart'; + +@JsonSerializable() +class AiRecipeRequest { + final List ingredients; + final String? dietType; + final List allergies; + final String? mood; // comfort, quick, healthy, fancy + final int? maxTimeMin; + final int servings; + final String? skillLevel; // beginner, intermediate, advanced + + const AiRecipeRequest({ + required this.ingredients, + this.dietType, + this.allergies = const [], + this.mood, + this.maxTimeMin, + this.servings = 4, + this.skillLevel, + }); + + factory AiRecipeRequest.fromJson(Map json) => _$AiRecipeRequestFromJson(json); + Map toJson() => _$AiRecipeRequestToJson(this); +} diff --git a/lib/src/models/ai_recipe_request.g.dart b/lib/src/models/ai_recipe_request.g.dart new file mode 100644 index 0000000..5c0e109 --- /dev/null +++ b/lib/src/models/ai_recipe_request.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ai_recipe_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AiRecipeRequest _$AiRecipeRequestFromJson( + Map json, +) => AiRecipeRequest( + ingredients: + (json['ingredients'] as List).map((e) => e as String).toList(), + dietType: json['dietType'] as String?, + allergies: + (json['allergies'] as List?)?.map((e) => e as String).toList() ?? + const [], + mood: json['mood'] as String?, + maxTimeMin: (json['maxTimeMin'] as num?)?.toInt(), + servings: (json['servings'] as num?)?.toInt() ?? 4, + skillLevel: json['skillLevel'] as String?, +); + +Map _$AiRecipeRequestToJson(AiRecipeRequest instance) => + { + 'ingredients': instance.ingredients, + 'dietType': instance.dietType, + 'allergies': instance.allergies, + 'mood': instance.mood, + 'maxTimeMin': instance.maxTimeMin, + 'servings': instance.servings, + 'skillLevel': instance.skillLevel, + }; diff --git a/lib/src/models/api_response.dart b/lib/src/models/api_response.dart new file mode 100644 index 0000000..4b16842 --- /dev/null +++ b/lib/src/models/api_response.dart @@ -0,0 +1,62 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'api_response.g.dart'; + +@JsonSerializable(genericArgumentFactories: true) +class ApiResponse { + final String status; // success, error + final T? data; + final ApiError? error; + final PaginationMeta? meta; + + const ApiResponse({ + required this.status, + this.data, + this.error, + this.meta, + }); + + factory ApiResponse.success(T data, {PaginationMeta? meta}) => + ApiResponse(status: 'success', data: data, meta: meta); + + factory ApiResponse.error(String code, String message) => + ApiResponse(status: 'error', error: ApiError(code: code, message: message)); + + factory ApiResponse.fromJson( + Map json, + T Function(Object? json) fromJsonT, + ) => _$ApiResponseFromJson(json, fromJsonT); + + Map toJson(Object? Function(T value) toJsonT) => + _$ApiResponseToJson(this, toJsonT); +} + +@JsonSerializable() +class ApiError { + final String code; + final String message; + final dynamic details; + + const ApiError({required this.code, required this.message, this.details}); + + factory ApiError.fromJson(Map json) => _$ApiErrorFromJson(json); + Map toJson() => _$ApiErrorToJson(this); +} + +@JsonSerializable() +class PaginationMeta { + final int page; + final int limit; + final int total; + final bool hasMore; + + const PaginationMeta({ + required this.page, + required this.limit, + required this.total, + required this.hasMore, + }); + + factory PaginationMeta.fromJson(Map json) => _$PaginationMetaFromJson(json); + Map toJson() => _$PaginationMetaToJson(this); +} diff --git a/lib/src/models/api_response.g.dart b/lib/src/models/api_response.g.dart new file mode 100644 index 0000000..32e327c --- /dev/null +++ b/lib/src/models/api_response.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ApiResponse _$ApiResponseFromJson( + Map json, + T Function(Object? json) fromJsonT, +) => ApiResponse( + status: json['status'] as String, + data: _$nullableGenericFromJson(json['data'], fromJsonT), + error: + json['error'] == null + ? null + : ApiError.fromJson(json['error'] as Map), + meta: + json['meta'] == null + ? null + : PaginationMeta.fromJson(json['meta'] as Map), +); + +Map _$ApiResponseToJson( + ApiResponse instance, + Object? Function(T value) toJsonT, +) => { + 'status': instance.status, + 'data': _$nullableGenericToJson(instance.data, toJsonT), + 'error': instance.error, + 'meta': instance.meta, +}; + +T? _$nullableGenericFromJson( + Object? input, + T Function(Object? json) fromJson, +) => input == null ? null : fromJson(input); + +Object? _$nullableGenericToJson( + T? input, + Object? Function(T value) toJson, +) => input == null ? null : toJson(input); + +ApiError _$ApiErrorFromJson(Map json) => ApiError( + code: json['code'] as String, + message: json['message'] as String, + details: json['details'], +); + +Map _$ApiErrorToJson(ApiError instance) => { + 'code': instance.code, + 'message': instance.message, + 'details': instance.details, +}; + +PaginationMeta _$PaginationMetaFromJson(Map json) => + PaginationMeta( + page: (json['page'] as num).toInt(), + limit: (json['limit'] as num).toInt(), + total: (json['total'] as num).toInt(), + hasMore: json['hasMore'] as bool, + ); + +Map _$PaginationMetaToJson(PaginationMeta instance) => + { + 'page': instance.page, + 'limit': instance.limit, + 'total': instance.total, + 'hasMore': instance.hasMore, + }; diff --git a/lib/src/models/cooking_step.dart b/lib/src/models/cooking_step.dart new file mode 100644 index 0000000..83891e3 --- /dev/null +++ b/lib/src/models/cooking_step.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'cooking_step.g.dart'; + +@JsonSerializable() +class CookingStep { + final String id; + final String recipeId; + final int stepNumber; + final String instruction; + final int? durationMin; + final String? imageUrl; + final String? tip; + + const CookingStep({ + required this.id, + required this.recipeId, + required this.stepNumber, + required this.instruction, + this.durationMin, + this.imageUrl, + this.tip, + }); + + factory CookingStep.fromJson(Map json) => _$CookingStepFromJson(json); + Map toJson() => _$CookingStepToJson(this); +} diff --git a/lib/src/models/cooking_step.g.dart b/lib/src/models/cooking_step.g.dart new file mode 100644 index 0000000..b07ec2c --- /dev/null +++ b/lib/src/models/cooking_step.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cooking_step.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CookingStep _$CookingStepFromJson(Map json) => CookingStep( + id: json['id'] as String, + recipeId: json['recipeId'] as String, + stepNumber: (json['stepNumber'] as num).toInt(), + instruction: json['instruction'] as String, + durationMin: (json['durationMin'] as num?)?.toInt(), + imageUrl: json['imageUrl'] as String?, + tip: json['tip'] as String?, +); + +Map _$CookingStepToJson(CookingStep instance) => + { + 'id': instance.id, + 'recipeId': instance.recipeId, + 'stepNumber': instance.stepNumber, + 'instruction': instance.instruction, + 'durationMin': instance.durationMin, + 'imageUrl': instance.imageUrl, + 'tip': instance.tip, + }; diff --git a/lib/src/models/dietary_profile.dart b/lib/src/models/dietary_profile.dart new file mode 100644 index 0000000..6ff040b --- /dev/null +++ b/lib/src/models/dietary_profile.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'dietary_profile.g.dart'; + +@JsonSerializable() +class DietaryProfile { + final String id; + final String userId; + final String? dietType; + final List allergies; + final List intolerances; + final int? calorieTarget; + final List excludedIngredients; + + const DietaryProfile({ + required this.id, + required this.userId, + this.dietType, + this.allergies = const [], + this.intolerances = const [], + this.calorieTarget, + this.excludedIngredients = const [], + }); + + factory DietaryProfile.fromJson(Map json) => _$DietaryProfileFromJson(json); + Map toJson() => _$DietaryProfileToJson(this); +} diff --git a/lib/src/models/dietary_profile.g.dart b/lib/src/models/dietary_profile.g.dart new file mode 100644 index 0000000..04dd1fe --- /dev/null +++ b/lib/src/models/dietary_profile.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dietary_profile.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DietaryProfile _$DietaryProfileFromJson(Map json) => + DietaryProfile( + id: json['id'] as String, + userId: json['userId'] as String, + dietType: json['dietType'] as String?, + allergies: + (json['allergies'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + intolerances: + (json['intolerances'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + calorieTarget: (json['calorieTarget'] as num?)?.toInt(), + excludedIngredients: + (json['excludedIngredients'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + ); + +Map _$DietaryProfileToJson(DietaryProfile instance) => + { + 'id': instance.id, + 'userId': instance.userId, + 'dietType': instance.dietType, + 'allergies': instance.allergies, + 'intolerances': instance.intolerances, + 'calorieTarget': instance.calorieTarget, + 'excludedIngredients': instance.excludedIngredients, + }; diff --git a/lib/src/models/ingredient.dart b/lib/src/models/ingredient.dart new file mode 100644 index 0000000..91443dc --- /dev/null +++ b/lib/src/models/ingredient.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'ingredient.g.dart'; + +@JsonSerializable() +class Ingredient { + final String id; + final String recipeId; + final String name; + final double? quantity; + final String? unit; + final String? groupName; + final int sortOrder; + final bool optional; + final String? canonicalIngredientId; + + const Ingredient({ + required this.id, + required this.recipeId, + required this.name, + this.quantity, + this.unit, + this.groupName, + required this.sortOrder, + this.optional = false, + this.canonicalIngredientId, + }); + + factory Ingredient.fromJson(Map json) => _$IngredientFromJson(json); + Map toJson() => _$IngredientToJson(this); +} diff --git a/lib/src/models/ingredient.g.dart b/lib/src/models/ingredient.g.dart new file mode 100644 index 0000000..8adc4f0 --- /dev/null +++ b/lib/src/models/ingredient.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ingredient.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Ingredient _$IngredientFromJson(Map json) => Ingredient( + id: json['id'] as String, + recipeId: json['recipeId'] as String, + name: json['name'] as String, + quantity: (json['quantity'] as num?)?.toDouble(), + unit: json['unit'] as String?, + groupName: json['groupName'] as String?, + sortOrder: (json['sortOrder'] as num).toInt(), + optional: json['optional'] as bool? ?? false, + canonicalIngredientId: json['canonicalIngredientId'] as String?, +); + +Map _$IngredientToJson(Ingredient instance) => + { + 'id': instance.id, + 'recipeId': instance.recipeId, + 'name': instance.name, + 'quantity': instance.quantity, + 'unit': instance.unit, + 'groupName': instance.groupName, + 'sortOrder': instance.sortOrder, + 'optional': instance.optional, + 'canonicalIngredientId': instance.canonicalIngredientId, + }; diff --git a/lib/src/models/nutrition_info.dart b/lib/src/models/nutrition_info.dart new file mode 100644 index 0000000..77509ac --- /dev/null +++ b/lib/src/models/nutrition_info.dart @@ -0,0 +1,37 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'nutrition_info.g.dart'; + +@JsonSerializable() +class NutritionInfo { + final String id; + final String recipeId; + final bool perServing; + final double? calories; + final double? proteinG; + final double? fatG; + final double? saturatedFatG; + final double? carbsG; + final double? fiberG; + final double? sugarG; + final double? sodiumMg; + final String source; // auto, manual, usda + + const NutritionInfo({ + required this.id, + required this.recipeId, + this.perServing = true, + this.calories, + this.proteinG, + this.fatG, + this.saturatedFatG, + this.carbsG, + this.fiberG, + this.sugarG, + this.sodiumMg, + this.source = 'auto', + }); + + factory NutritionInfo.fromJson(Map json) => _$NutritionInfoFromJson(json); + Map toJson() => _$NutritionInfoToJson(this); +} diff --git a/lib/src/models/nutrition_info.g.dart b/lib/src/models/nutrition_info.g.dart new file mode 100644 index 0000000..ab241f3 --- /dev/null +++ b/lib/src/models/nutrition_info.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nutrition_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NutritionInfo _$NutritionInfoFromJson(Map json) => + NutritionInfo( + id: json['id'] as String, + recipeId: json['recipeId'] as String, + perServing: json['perServing'] as bool? ?? true, + calories: (json['calories'] as num?)?.toDouble(), + proteinG: (json['proteinG'] as num?)?.toDouble(), + fatG: (json['fatG'] as num?)?.toDouble(), + saturatedFatG: (json['saturatedFatG'] as num?)?.toDouble(), + carbsG: (json['carbsG'] as num?)?.toDouble(), + fiberG: (json['fiberG'] as num?)?.toDouble(), + sugarG: (json['sugarG'] as num?)?.toDouble(), + sodiumMg: (json['sodiumMg'] as num?)?.toDouble(), + source: json['source'] as String? ?? 'auto', + ); + +Map _$NutritionInfoToJson(NutritionInfo instance) => + { + 'id': instance.id, + 'recipeId': instance.recipeId, + 'perServing': instance.perServing, + 'calories': instance.calories, + 'proteinG': instance.proteinG, + 'fatG': instance.fatG, + 'saturatedFatG': instance.saturatedFatG, + 'carbsG': instance.carbsG, + 'fiberG': instance.fiberG, + 'sugarG': instance.sugarG, + 'sodiumMg': instance.sodiumMg, + 'source': instance.source, + }; diff --git a/lib/src/models/recipe.dart b/lib/src/models/recipe.dart new file mode 100644 index 0000000..4115b96 --- /dev/null +++ b/lib/src/models/recipe.dart @@ -0,0 +1,70 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'ingredient.dart'; +import 'cooking_step.dart'; +import 'nutrition_info.dart'; + +part 'recipe.g.dart'; + +@JsonSerializable() +class Recipe { + final String id; + final String? authorId; + final String title; + final String slug; + final String? description; + final String? cuisine; + final String? difficulty; // easy, medium, hard + final int? prepTimeMin; + final int? cookTimeMin; + final int? totalTimeMin; + final int servings; + final String sourceType; // user, ai, curated + final String status; // draft, pending, published, rejected + final String? coverImageUrl; + final String? videoUrl; + final List tags; + final List dietLabels; + final double ratingAvg; + final int ratingCount; + final int saveCount; + final int version; + final DateTime createdAt; + final DateTime? updatedAt; + final DateTime? publishedAt; + final List? ingredients; + final List? steps; + final NutritionInfo? nutrition; + + const Recipe({ + required this.id, + this.authorId, + required this.title, + required this.slug, + this.description, + this.cuisine, + this.difficulty, + this.prepTimeMin, + this.cookTimeMin, + this.totalTimeMin, + this.servings = 4, + this.sourceType = 'user', + this.status = 'published', + this.coverImageUrl, + this.videoUrl, + this.tags = const [], + this.dietLabels = const [], + this.ratingAvg = 0, + this.ratingCount = 0, + this.saveCount = 0, + this.version = 1, + required this.createdAt, + this.updatedAt, + this.publishedAt, + this.ingredients, + this.steps, + this.nutrition, + }); + + factory Recipe.fromJson(Map json) => _$RecipeFromJson(json); + Map toJson() => _$RecipeToJson(this); +} diff --git a/lib/src/models/recipe.g.dart b/lib/src/models/recipe.g.dart new file mode 100644 index 0000000..3980608 --- /dev/null +++ b/lib/src/models/recipe.g.dart @@ -0,0 +1,88 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recipe.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Recipe _$RecipeFromJson(Map json) => Recipe( + id: json['id'] as String, + authorId: json['authorId'] as String?, + title: json['title'] as String, + slug: json['slug'] as String, + description: json['description'] as String?, + cuisine: json['cuisine'] as String?, + difficulty: json['difficulty'] as String?, + prepTimeMin: (json['prepTimeMin'] as num?)?.toInt(), + cookTimeMin: (json['cookTimeMin'] as num?)?.toInt(), + totalTimeMin: (json['totalTimeMin'] as num?)?.toInt(), + servings: (json['servings'] as num?)?.toInt() ?? 4, + sourceType: json['sourceType'] as String? ?? 'user', + status: json['status'] as String? ?? 'published', + coverImageUrl: json['coverImageUrl'] as String?, + videoUrl: json['videoUrl'] as String?, + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? + const [], + dietLabels: + (json['dietLabels'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + ratingAvg: (json['ratingAvg'] as num?)?.toDouble() ?? 0, + ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0, + saveCount: (json['saveCount'] as num?)?.toInt() ?? 0, + version: (json['version'] as num?)?.toInt() ?? 1, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: + json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), + publishedAt: + json['publishedAt'] == null + ? null + : DateTime.parse(json['publishedAt'] as String), + ingredients: + (json['ingredients'] as List?) + ?.map((e) => Ingredient.fromJson(e as Map)) + .toList(), + steps: + (json['steps'] as List?) + ?.map((e) => CookingStep.fromJson(e as Map)) + .toList(), + nutrition: + json['nutrition'] == null + ? null + : NutritionInfo.fromJson(json['nutrition'] as Map), +); + +Map _$RecipeToJson(Recipe instance) => { + 'id': instance.id, + 'authorId': instance.authorId, + 'title': instance.title, + 'slug': instance.slug, + 'description': instance.description, + 'cuisine': instance.cuisine, + 'difficulty': instance.difficulty, + 'prepTimeMin': instance.prepTimeMin, + 'cookTimeMin': instance.cookTimeMin, + 'totalTimeMin': instance.totalTimeMin, + 'servings': instance.servings, + 'sourceType': instance.sourceType, + 'status': instance.status, + 'coverImageUrl': instance.coverImageUrl, + 'videoUrl': instance.videoUrl, + 'tags': instance.tags, + 'dietLabels': instance.dietLabels, + 'ratingAvg': instance.ratingAvg, + 'ratingCount': instance.ratingCount, + 'saveCount': instance.saveCount, + 'version': instance.version, + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), + 'publishedAt': instance.publishedAt?.toIso8601String(), + 'ingredients': instance.ingredients, + 'steps': instance.steps, + 'nutrition': instance.nutrition, +}; diff --git a/lib/src/models/review.dart b/lib/src/models/review.dart new file mode 100644 index 0000000..d2af24a --- /dev/null +++ b/lib/src/models/review.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'review.g.dart'; + +@JsonSerializable() +class Review { + final String id; + final String recipeId; + final String userId; + final String? userDisplayName; + final int rating; // 1-5 + final String? title; + final String? body; + final int helpfulCount; + final DateTime createdAt; + final DateTime? updatedAt; + + const Review({ + required this.id, + required this.recipeId, + required this.userId, + this.userDisplayName, + required this.rating, + this.title, + this.body, + this.helpfulCount = 0, + required this.createdAt, + this.updatedAt, + }); + + factory Review.fromJson(Map json) => _$ReviewFromJson(json); + Map toJson() => _$ReviewToJson(this); +} diff --git a/lib/src/models/review.g.dart b/lib/src/models/review.g.dart new file mode 100644 index 0000000..b8d594d --- /dev/null +++ b/lib/src/models/review.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'review.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Review _$ReviewFromJson(Map json) => Review( + id: json['id'] as String, + recipeId: json['recipeId'] as String, + userId: json['userId'] as String, + userDisplayName: json['userDisplayName'] as String?, + rating: (json['rating'] as num).toInt(), + title: json['title'] as String?, + body: json['body'] as String?, + helpfulCount: (json['helpfulCount'] as num?)?.toInt() ?? 0, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: + json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), +); + +Map _$ReviewToJson(Review instance) => { + 'id': instance.id, + 'recipeId': instance.recipeId, + 'userId': instance.userId, + 'userDisplayName': instance.userDisplayName, + 'rating': instance.rating, + 'title': instance.title, + 'body': instance.body, + 'helpfulCount': instance.helpfulCount, + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), +}; diff --git a/lib/src/models/user.dart b/lib/src/models/user.dart new file mode 100644 index 0000000..616b92d --- /dev/null +++ b/lib/src/models/user.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'user.g.dart'; + +@JsonSerializable() +class User { + final String id; + final String email; + final String displayName; + final String? avatarUrl; + final String subscriptionTier; // free, premium + final DateTime? subscriptionExpiresAt; + final DateTime createdAt; + final DateTime? updatedAt; + + const User({ + required this.id, + required this.email, + required this.displayName, + this.avatarUrl, + this.subscriptionTier = 'free', + this.subscriptionExpiresAt, + required this.createdAt, + this.updatedAt, + }); + + factory User.fromJson(Map json) => _$UserFromJson(json); + Map toJson() => _$UserToJson(this); +} diff --git a/lib/src/models/user.g.dart b/lib/src/models/user.g.dart new file mode 100644 index 0000000..9e553fb --- /dev/null +++ b/lib/src/models/user.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +User _$UserFromJson(Map json) => User( + id: json['id'] as String, + email: json['email'] as String, + displayName: json['displayName'] as String, + avatarUrl: json['avatarUrl'] as String?, + subscriptionTier: json['subscriptionTier'] as String? ?? 'free', + subscriptionExpiresAt: + json['subscriptionExpiresAt'] == null + ? null + : DateTime.parse(json['subscriptionExpiresAt'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: + json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), +); + +Map _$UserToJson(User instance) => { + 'id': instance.id, + 'email': instance.email, + 'displayName': instance.displayName, + 'avatarUrl': instance.avatarUrl, + 'subscriptionTier': instance.subscriptionTier, + 'subscriptionExpiresAt': instance.subscriptionExpiresAt?.toIso8601String(), + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), +}; diff --git a/lib/src/validators/recipe_validator.dart b/lib/src/validators/recipe_validator.dart new file mode 100644 index 0000000..1f73ccc --- /dev/null +++ b/lib/src/validators/recipe_validator.dart @@ -0,0 +1,37 @@ +import '../constants/app_limits.dart'; + +class RecipeValidator { + RecipeValidator._(); + + static String? validateTitle(String? title) { + if (title == null || title.trim().isEmpty) return 'Title is required'; + if (title.length > AppLimits.maxRecipeTitleLength) { + return 'Title must be ${AppLimits.maxRecipeTitleLength} characters or less'; + } + return null; + } + + static String? validateServings(int? servings) { + if (servings == null || servings < 1) return 'Servings must be at least 1'; + if (servings > 100) return 'Servings must be 100 or less'; + return null; + } + + static String? validateRating(int? rating) { + if (rating == null) return 'Rating is required'; + if (rating < 1 || rating > 5) return 'Rating must be between 1 and 5'; + return null; + } + + static String? validatePrepTime(int? minutes) { + if (minutes != null && minutes < 0) return 'Prep time cannot be negative'; + if (minutes != null && minutes > 1440) return 'Prep time cannot exceed 24 hours'; + return null; + } + + static String? validateCookTime(int? minutes) { + if (minutes != null && minutes < 0) return 'Cook time cannot be negative'; + if (minutes != null && minutes > 2880) return 'Cook time cannot exceed 48 hours'; + return null; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..521db1e --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,19 @@ +name: cleanplate_shared +description: Shared models, enums, and utilities for CleanPlate recipe app +version: 0.1.0 +publish_to: 'none' + +environment: + sdk: ^3.7.0 + +dependencies: + json_annotation: ^4.9.0 + freezed_annotation: ^2.4.0 + equatable: ^2.0.7 + +dev_dependencies: + build_runner: ^2.4.0 + json_serializable: ^6.8.0 + freezed: ^2.5.0 + lints: ^5.0.0 + test: ^1.24.0 diff --git a/test/cleanplate_shared_test.dart b/test/cleanplate_shared_test.dart new file mode 100644 index 0000000..66ee4d1 --- /dev/null +++ b/test/cleanplate_shared_test.dart @@ -0,0 +1,46 @@ +import 'package:cleanplate_shared/cleanplate_shared.dart'; +import 'package:test/test.dart'; + +void main() { + group('RecipeValidator', () { + test('validateTitle returns null for valid title', () { + expect(RecipeValidator.validateTitle('My Recipe'), isNull); + }); + + test('validateTitle returns error for empty title', () { + expect(RecipeValidator.validateTitle(''), isNotNull); + }); + + test('validateServings returns null for valid servings', () { + expect(RecipeValidator.validateServings(4), isNull); + }); + + test('validateServings returns error for zero servings', () { + expect(RecipeValidator.validateServings(0), isNotNull); + }); + + test('validateRating returns null for valid rating', () { + expect(RecipeValidator.validateRating(5), isNull); + }); + + test('validateRating returns error for out-of-range rating', () { + expect(RecipeValidator.validateRating(6), isNotNull); + }); + }); + + group('DietType', () { + test('displayName returns correct value', () { + expect(DietType.vegan.displayName, equals('Vegan')); + }); + }); + + group('ApiConstants', () { + test('basePath includes version', () { + expect(ApiConstants.basePath, equals('/api/v1')); + }); + + test('recipe path includes id', () { + expect(ApiConstants.recipe('abc'), equals('/api/v1/recipes/abc')); + }); + }); +}