Reshape shared/commonMain domain model
Replace the 11 hand-rolled model files with 7 grouped by concern. Typed ID value classes kill bare-string FKs. Canonical 3-value MeasurementUnit enum kills the runtime unitMismatch class — Polish vocabulary lives in Quantity.displayHint as render-only metadata. MealExtras (5 maps) collapses into IngredientCustomization + PlanCustomization. IngredientCategory and MealSlot become household-scoped entities with LocalizedString names so they're customizable without an app release. Display names land as LocalizedString from day one; no Polish strings in identifiers or wire codes. Recipe drops allowedSlots — slot affinity is a UI-layer match on Recipe.tags vs MealSlot.name. Skip is absence, not a sealed sibling. Plan: ~/.claude/plans/i-have-generated-some-inherited-conway.md. Covered by SerializationRoundTripTest: 12 assertions across typed-ID inlining, MeasurementUnit wire format, LocalizedString JSON shape, full PlanEntry round-trip with every customization kind, SyncMeta tombstone omission, and Catalog defaults handling. All targets compile and pass: JVM, Android (debug + release), iOS Simulator Arm64.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package dev.ulfrx.recipe.shared.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ── Value-typed metadata ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Macros per 100 g (or 100 ml for liquid ingredients). Daily-total aggregation
|
||||
* (PLAN-13) reads these from ingredients/products and scales by the canonical
|
||||
* quantity at meal time.
|
||||
*/
|
||||
@Serializable
|
||||
public data class NutritionPer100(
|
||||
public val kcal: Double,
|
||||
public val protein: Double,
|
||||
public val fat: Double,
|
||||
public val carbs: Double,
|
||||
)
|
||||
|
||||
/**
|
||||
* Typical retail pack for an ingredient or a specific product (e.g.
|
||||
* `PurchasePack(125.0, "125 g")`). Shopping list rounding uses this to suggest
|
||||
* realistic pack-sized purchases instead of raw recipe weights.
|
||||
*/
|
||||
@Serializable
|
||||
public data class PurchasePack(
|
||||
public val amount: Double,
|
||||
public val label: String,
|
||||
)
|
||||
|
||||
// ── Household-scoped taxonomy ────────────────────────────────────────────
|
||||
//
|
||||
// IngredientCategory and MealSlot are user-curated per household: each
|
||||
// household ships with a Polish default list at creation (Phase 3 server
|
||||
// concern) and can rename / reorder / extend freely. Both LWW-sync via SyncMeta
|
||||
// the same way other household data does.
|
||||
|
||||
/**
|
||||
* A pantry / shopping grouping the household uses (`Pieczywo`, `Nabiał`,
|
||||
* `Mięso i ryby`, …). [ordinal] drives display order in the pantry and
|
||||
* shopping list. [name] is localized per the household's locale palette.
|
||||
*/
|
||||
@Serializable
|
||||
public data class IngredientCategory(
|
||||
public val id: IngredientCategoryId,
|
||||
public val sync: SyncMeta,
|
||||
public val name: LocalizedString,
|
||||
public val ordinal: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* A meal-time slot a household plans into (`Śniadanie`, `Drugie śniadanie`,
|
||||
* `Obiad`, `Przekąska`, `Kolacja`, plus anything custom). [ordinal] drives the
|
||||
* within-day order in the planner. Slots are referenced by [PlanEntry.slotId].
|
||||
*
|
||||
* Recipes don't pin themselves to specific slots — slot affinity is a UI-layer
|
||||
* match between [Recipe.tags] and [name] localized values, so households can
|
||||
* rename slots without invalidating the catalog.
|
||||
*/
|
||||
@Serializable
|
||||
public data class MealSlot(
|
||||
public val id: MealSlotId,
|
||||
public val sync: SyncMeta,
|
||||
public val name: LocalizedString,
|
||||
public val ordinal: Int,
|
||||
)
|
||||
|
||||
// ── Catalog entities (server-owned in v1, household-editable later) ──────
|
||||
//
|
||||
// Ingredient / Product / Recipe carry SyncMeta so that future client-side
|
||||
// catalog writes land on the same LWW path as everything else; v1 only reads
|
||||
// them on the client.
|
||||
|
||||
/**
|
||||
* A catalog-level ingredient (mąka, oliwa, jajko, …). [pantryUnit] is the
|
||||
* canonical unit this ingredient is tracked in for pantry math; recipe lines
|
||||
* targeting this ingredient must produce a [Quantity] in the same unit (or one
|
||||
* that converts cleanly — pieces ↔ grams via [weightPerPieceG]).
|
||||
*
|
||||
* [weightPerPieceG] is only meaningful when [pantryUnit] == [MeasurementUnit.PIECE];
|
||||
* leaving it null on a piece-tracked ingredient blocks nutrition aggregation.
|
||||
* [purchasePack] feeds shopping rounding; [nutritionPer100] feeds daily totals.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Ingredient(
|
||||
public val id: IngredientId,
|
||||
public val sync: SyncMeta,
|
||||
public val name: LocalizedString,
|
||||
public val categoryId: IngredientCategoryId,
|
||||
public val pantryUnit: MeasurementUnit,
|
||||
public val weightPerPieceG: Double? = null,
|
||||
public val purchasePack: PurchasePack? = null,
|
||||
public val nutritionPer100: NutritionPer100? = null,
|
||||
public val imagePath: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A branded / packaged variant of an [Ingredient]. Products carry their own
|
||||
* [nutritionPer100] because brand A's twaróg has different macros than brand
|
||||
* B's, and per-entry [PlanCustomization.overrides] can pick a specific product.
|
||||
* [brand] is plain `String?` — brand names are proper nouns and don't translate.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Product(
|
||||
public val id: ProductId,
|
||||
public val sync: SyncMeta,
|
||||
public val ingredientId: IngredientId,
|
||||
public val name: LocalizedString,
|
||||
public val brand: String? = null,
|
||||
public val pack: PurchasePack,
|
||||
public val nutritionPer100: NutritionPer100? = null,
|
||||
public val imagePath: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One ingredient line on a recipe. [alternatives] is metadata only — the
|
||||
* planner UI surfaces these as suggested swaps, but the actual per-entry
|
||||
* substitution is recorded in [IngredientCustomization.substituteWith].
|
||||
*/
|
||||
@Serializable
|
||||
public data class RecipeIngredient(
|
||||
public val ingredientId: IngredientId,
|
||||
public val quantity: Quantity,
|
||||
public val alternatives: List<IngredientId> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Catalog-level recipe definition. Synced via the same LWW path as household
|
||||
* data so future client-side recipe editing lands additively. Recipes are
|
||||
* server-seeded in v1 and read-only on the client.
|
||||
*
|
||||
* No `allowedSlots` field — slot affinity is a UI-layer concern using [tags]
|
||||
* matched against [MealSlot.name] localized values. Recipes ship with Polish
|
||||
* slot tags so default households work out of the box.
|
||||
*
|
||||
* [nutritionPerServing] is the cached pre-customization total; the calculator
|
||||
* skips the per-ingredient walk when no [PlanCustomization] is present.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Recipe(
|
||||
public val id: RecipeId,
|
||||
public val sync: SyncMeta,
|
||||
public val title: LocalizedString,
|
||||
public val minutes: Int,
|
||||
public val tags: List<String> = emptyList(),
|
||||
public val steps: List<LocalizedString> = emptyList(),
|
||||
public val ingredients: List<RecipeIngredient>,
|
||||
public val nutritionPerServing: NutritionPer100,
|
||||
public val imagePath: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package dev.ulfrx.recipe.shared.domain.model
|
||||
|
||||
import kotlin.jvm.JvmInline
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Typed id wrappers. `@Serializable @JvmInline value class` serializes as the
|
||||
// underlying string in JSON (no `{"raw":"..."}` envelope) and erases to a
|
||||
// String at runtime on JVM/Native — zero wire-format change, zero overhead,
|
||||
// full compile-time protection against passing a RecipeId where an
|
||||
// IngredientId is expected.
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class HouseholdId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class UserId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class RecipeId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class IngredientId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class ProductId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class PlanEntryId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class PantryItemId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class ShoppingListId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class ShoppingItemId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class IngredientCategoryId(public val raw: String)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class MealSlotId(public val raw: String)
|
||||
@@ -0,0 +1,52 @@
|
||||
package dev.ulfrx.recipe.shared.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One stock row for an ingredient — optionally pinned to a specific [Product].
|
||||
*
|
||||
* The mockup's hybrid `Number-or-{total, items[]}` storage collapses to
|
||||
* multiple flat rows: one with `productId == null` for generic stock, plus a
|
||||
* row per tracked product. Aggregating "how much of X do I have?" is a sum
|
||||
* across the rows sharing an [ingredientId].
|
||||
*/
|
||||
@Serializable
|
||||
public data class PantryItem(
|
||||
public val id: PantryItemId,
|
||||
public val sync: SyncMeta,
|
||||
public val ingredientId: IngredientId,
|
||||
public val productId: ProductId? = null,
|
||||
public val quantity: Quantity,
|
||||
)
|
||||
|
||||
/**
|
||||
* A named shopping list (most households use the default "Kitchen" list;
|
||||
* additional lists are user-created). [ordinal] orders multiple lists in the
|
||||
* UI. [name] is plain `String` — user-supplied free text, not catalog-localized.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ShoppingList(
|
||||
public val id: ShoppingListId,
|
||||
public val sync: SyncMeta,
|
||||
public val name: String,
|
||||
public val ordinal: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* One line on a shopping list. [productId] pins a specific brand when present.
|
||||
* [checked] tracks "bought" state; checking + later sync into pantry happens at
|
||||
* the repository layer. [sourceNote] is a free-text provenance hint ("From
|
||||
* plan", "Ze spiżarni"); the v1 UI surfaces it but doesn't filter on it.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ShoppingItem(
|
||||
public val id: ShoppingItemId,
|
||||
public val sync: SyncMeta,
|
||||
public val listId: ShoppingListId,
|
||||
public val ingredientId: IngredientId,
|
||||
public val productId: ProductId? = null,
|
||||
public val quantity: Quantity,
|
||||
public val checked: Boolean = false,
|
||||
public val sourceNote: String? = null,
|
||||
public val ordinal: Int,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package dev.ulfrx.recipe.shared.domain.model
|
||||
|
||||
import kotlin.jvm.JvmInline
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* BCP-47 locale tag, e.g. `"pl"`, `"en"`, `"pl-PL"`. Held as a typed value
|
||||
* class so localized-name maps don't get confused with arbitrary string keys.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
public value class LocaleTag(public val raw: String)
|
||||
|
||||
/**
|
||||
* A user-facing string in one or more locales. Stored as a flat JSON object
|
||||
* keyed by locale tag (e.g. `{"pl": "Śniadanie", "en": "Breakfast"}`). v1 seed
|
||||
* data ships with at least the Polish key populated; additional locales are
|
||||
* additive.
|
||||
*/
|
||||
public typealias LocalizedString = Map<LocaleTag, String>
|
||||
|
||||
/**
|
||||
* Resolve a localized string for the requested [locale], falling back to
|
||||
* [fallback] (default Polish), then to any populated value, then to an empty
|
||||
* string. The empty-string fallback is intentional — UI code should never crash
|
||||
* because a translation hasn't shipped yet.
|
||||
*/
|
||||
public fun LocalizedString.forLocale(
|
||||
locale: LocaleTag,
|
||||
fallback: LocaleTag = LocaleTag("pl"),
|
||||
): String =
|
||||
this[locale]
|
||||
?: this[fallback]
|
||||
?: values.firstOrNull()
|
||||
?: ""
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.ulfrx.recipe.shared.domain.model
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Per-ingredient overrides applied to a planned meal. All fields are optional;
|
||||
* an entry is only stored in [PlanCustomization.overrides] when at least one
|
||||
* field is non-default. The repository prunes default-only entries on write.
|
||||
*
|
||||
* The four fields cover PLAN-07 through PLAN-11 (substitutions, exclusions,
|
||||
* amount overrides, product picks) collapsed onto a single per-ingredient row
|
||||
* so they can compose — substituting almonds for walnuts AND overriding the
|
||||
* amount AND pinning a brand all land on the same record.
|
||||
*/
|
||||
@Serializable
|
||||
public data class IngredientCustomization(
|
||||
public val substituteWith: IngredientId? = null,
|
||||
public val excluded: Boolean = false,
|
||||
public val amountOverride: Quantity? = null,
|
||||
public val productPick: ProductId? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* An ingredient injected into a planned meal that isn't part of the original
|
||||
* recipe (PLAN-10). [productPick] optionally pins a specific [Product].
|
||||
*/
|
||||
@Serializable
|
||||
public data class AddedIngredient(
|
||||
public val ingredientId: IngredientId,
|
||||
public val quantity: Quantity,
|
||||
public val productPick: ProductId? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* All customizations attached to a single [PlanEntry]. Defaults are empty —
|
||||
* a Phase 6 entry without customizations stays cheap.
|
||||
*/
|
||||
@Serializable
|
||||
public data class PlanCustomization(
|
||||
public val overrides: Map<IngredientId, IngredientCustomization> = emptyMap(),
|
||||
public val added: List<AddedIngredient> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One planned meal: a recipe at a date/slot pair. Identity is the UUID
|
||||
* [PlanEntryId] — never the composite `(date, slot)` — so concurrent edits
|
||||
* across devices can survive a delete + recreate race without colliding on a
|
||||
* natural key (PLAN-14).
|
||||
*
|
||||
* "Skipped slot" (PLAN-12) is modeled as absence — no [PlanEntry] row exists
|
||||
* for that `(date, slotId)`. No sentinel field, no sealed sibling.
|
||||
*/
|
||||
@Serializable
|
||||
public data class PlanEntry(
|
||||
public val id: PlanEntryId,
|
||||
public val sync: SyncMeta,
|
||||
public val date: LocalDate,
|
||||
public val slotId: MealSlotId,
|
||||
public val recipeId: RecipeId,
|
||||
public val servings: Double,
|
||||
public val customization: PlanCustomization = PlanCustomization(),
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.ulfrx.recipe.shared.domain.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Per-row sync footer for every household-scoped or catalog entity.
|
||||
*
|
||||
* All three timestamps are server-assigned, never client-supplied — the
|
||||
* monotonic `(updatedAt, id)` pair is the pull cursor, and a client-set
|
||||
* `updatedAt` would break LWW conflict resolution across devices with drifted
|
||||
* clocks. [deletedAt] is the soft-delete tombstone; rows with `deletedAt != null`
|
||||
* still ship through sync so deletes propagate to other devices.
|
||||
*/
|
||||
@Serializable
|
||||
public data class SyncMeta(
|
||||
public val id: String,
|
||||
public val householdId: HouseholdId,
|
||||
public val createdAt: Instant,
|
||||
public val updatedAt: Instant,
|
||||
public val deletedAt: Instant? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A household — the tenancy boundary for every per-household entity (plan,
|
||||
* pantry, shopping list, the household-scoped taxonomy entries in
|
||||
* [IngredientCategory] / [MealSlot]). Members share a single household; v1 has
|
||||
* one active household per user.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Household(
|
||||
public val id: HouseholdId,
|
||||
public val name: String,
|
||||
public val createdAt: Instant,
|
||||
public val updatedAt: Instant,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.ulfrx.recipe.shared.domain.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The three canonical units the whole system computes on. Polish vocabulary
|
||||
* like `"łyżka"`, `"puszka"`, `"szczypta"` is NOT a unit — it lives in
|
||||
* [Quantity.displayHint] as render-only metadata while the math runs on this
|
||||
* fixed enum.
|
||||
*/
|
||||
@Serializable
|
||||
public enum class MeasurementUnit {
|
||||
@SerialName("gram")
|
||||
GRAM,
|
||||
|
||||
@SerialName("milliliter")
|
||||
MILLILITER,
|
||||
|
||||
@SerialName("piece")
|
||||
PIECE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical [amount] in a fixed [unit], with an optional human-readable
|
||||
* [displayHint] for UI rendering.
|
||||
*
|
||||
* The hint lets a recipe say "2 łyżki oleju" (stored as
|
||||
* `Quantity(30.0, MILLILITER, {"pl": "2 łyżki"})`) and still aggregate cleanly
|
||||
* with another row's `100ml` because both are millilitres internally. Catalog
|
||||
* ingest is responsible for choosing the canonical amount; the domain model
|
||||
* just stores the result.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Quantity(
|
||||
public val amount: Double,
|
||||
public val unit: MeasurementUnit,
|
||||
public val displayHint: LocalizedString? = null,
|
||||
)
|
||||
Reference in New Issue
Block a user