From 067b9aeb63b7beca5d602197815a567c6ad6bc18 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 27 Jul 2026 20:18:10 -0300 Subject: [PATCH 1/2] refactor: share AI prompts and response parsing across providers The system prompts, wardrobe summary and JSON-to-domain mapping were private to ClaudeApiClient. A second AI provider needs all of it, and both must ask for and parse the exact same JSON contract, so move it to a provider-neutral data/source/ai package. Claude's public API and wire format are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../GapRecommendationParsingTest.kt | 4 +- .../github/worn/data/source/ai/AiPrompts.kt | 107 ++++++++++++ .../worn/data/source/ai/AiResponseModels.kt | 41 +++++ .../worn/data/source/ai/AiResponseParser.kt | 92 ++++++++++ .../data/source/remote/ClaudeApiClient.kt | 157 ++---------------- .../data/source/remote/ClaudeApiModels.kt | 32 ---- 6 files changed, 253 insertions(+), 180 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiPrompts.kt create mode 100644 shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiResponseModels.kt create mode 100644 shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiResponseParser.kt diff --git a/shared/src/androidHostTest/kotlin/com/github/worn/repository/GapRecommendationParsingTest.kt b/shared/src/androidHostTest/kotlin/com/github/worn/repository/GapRecommendationParsingTest.kt index a1493e4..f584d5d 100644 --- a/shared/src/androidHostTest/kotlin/com/github/worn/repository/GapRecommendationParsingTest.kt +++ b/shared/src/androidHostTest/kotlin/com/github/worn/repository/GapRecommendationParsingTest.kt @@ -1,7 +1,7 @@ package com.github.worn.repository -import com.github.worn.data.source.remote.GapRecommendationJson -import com.github.worn.data.source.remote.toDomain +import com.github.worn.data.source.ai.GapRecommendationJson +import com.github.worn.data.source.ai.toDomain import com.github.worn.domain.model.Category import com.github.worn.domain.model.Fit import com.github.worn.domain.model.Material diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiPrompts.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiPrompts.kt new file mode 100644 index 0000000..f14ac23 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiPrompts.kt @@ -0,0 +1,107 @@ +package com.github.worn.data.source.ai + +import com.github.worn.domain.model.ClothingItem +import com.github.worn.domain.model.UserProfile + +/** + * Prompt text shared by every AI provider. + * + * Prompts live here rather than inside a client so the cloud ([ClaudeApiClient][ + * com.github.worn.data.source.remote.ClaudeApiClient]) and on-device ([OnDeviceAiSource]) + * providers ask for the exact same JSON contract, and [AiResponseParser] can parse either + * reply with one code path. + */ +internal object AiPrompts { + + /** + * On-device models are small and drift toward prose or fenced code even when told not to. + * Appended to the prompts they serve; the Claude prompts are left untouched so existing + * request-body assertions keep passing. + */ + const val STRICT_JSON_SUFFIX = "\n\nOutput raw JSON only. No markdown, no code fences, " + + "no explanation before or after." + + val ANALYZE_SYSTEM_PROMPT = """ + You are a men's fashion analysis AI specialized in capsule wardrobe building. + Analyze the clothing item in the image. + Respond with ONLY a JSON object (no markdown): + { + "description": "brief description of the item", + "suggested_category": "one of: TOP, BOTTOM, OUTERWEAR, SHOES, ACCESSORY", + "colors": ["color1", "color2"], + "seasons": ["one or more of: SPRING, SUMMER, FALL, WINTER"], + "tags": ["tag1", "tag2", "tag3"], + "suggested_subcategory": "one of: $SUBCATEGORY_VALUES", + "suggested_fit": "one of: SLIM_FIT, REGULAR, RELAXED, OVERSIZED", + "suggested_material": "one of: COTTON, LINEN, DENIM, WOOL, SYNTHETIC, LEATHER, SILK, KNIT" + } + """.trimIndent() + + val GAPS_SYSTEM_PROMPT = """ + You are a men's capsule wardrobe analysis AI. Given a user's wardrobe, suggest + versatile items that would maximize outfit combinations following capsule wardrobe + principles. Prioritize timeless, mix-and-match pieces over trendy items. + Group suggestions by category (BASICS, LAYERING, BOTTOMS, SHOES, ACCESSORIES). + Respond with ONLY a JSON array (no markdown): + [{"item_name": "...", "category": "...", "pairing_count": N, + "subcategory": "one of: $SUBCATEGORY_VALUES", + "colors": ["color1"], + "seasons": ["SPRING", "SUMMER", "FALL", "WINTER"], + "fit": "one of: SLIM_FIT, REGULAR, RELAXED, OVERSIZED", + "material": "one of: COTTON, LINEN, DENIM, WOOL, SYNTHETIC, LEATHER, SILK, KNIT"}] + """.trimIndent() + + val TRY_IT_SYSTEM_PROMPT = """ + You are a men's capsule wardrobe analysis AI. Given a photo of a prospective + clothing item and the user's existing wardrobe, evaluate how well this item + contributes to versatility and outfit combinations following capsule wardrobe + principles. + Respond with ONLY a JSON object (no markdown): + { + "matching_item_ids": ["id1", "id2"], + "combinations_unlocked": N, + "gaps_filled": ["gap description 1", "gap description 2"], + "worth_adding": true/false + } + """.trimIndent() + + /** + * One line per item. [includeIds] prefixes each line with the item id, which only the + * try-it prompt needs — it asks the model to echo ids back in `matching_item_ids`. + */ + fun wardrobeSummary(items: List, includeIds: Boolean = false): String = + items.joinToString("\n") { item -> + buildString { + append("- ") + if (includeIds) append("[${item.id}] ") + append("${item.name} (${item.category}") + item.subcategory?.let { append(", type: $it") } + append(", colors: ${item.colors.joinToString()}") + append(", seasons: ${item.seasons.joinToString()}") + item.fit?.let { append(", fit: $it") } + item.material?.let { append(", material: $it") } + append(")") + } + } + + fun UserProfile.toPromptContext(): String { + val parts = mutableListOf() + bodyType?.let { parts.add("Body type: ${it.name.lowercase().replace('_', ' ')}") } + styleProfile?.let { parts.add("Style: ${it.name.lowercase().replace('_', ' ')}") } + ageRange?.let { + parts.add( + "Age range: ${it.name.removePrefix("AGE_").replace('_', '-').replace("PLUS", "+")}", + ) + } + climate?.let { parts.add("Climate: ${it.name.lowercase()}") } + if (lifestyles.isNotEmpty()) { + parts.add("Lifestyle: ${lifestyles.joinToString { it.name.lowercase().replace('_', ' ') }}") + } + return if (parts.isEmpty()) "" else "User profile:\n${parts.joinToString("\n")}\n\n" + } +} + +private const val SUBCATEGORY_VALUES = "T_SHIRT, POLO, DRESS_SHIRT, HENLEY, SWEATER, HOODIE, " + + "JEANS, CHINOS, TAILORED_PANTS, SHORTS, CARGO_PANTS, SWEATPANTS, BOMBER, TRUCKER, PUFFER, " + + "BLAZER, COAT, WINDBREAKER, SNEAKERS, BOOTS_MILITARY, BOOTS_CHELSEA, DERBY, OXFORD, LOAFER, " + + "SANDALS, WATCH, BELT, SUNGLASSES, HAT_CAP, SCARF, BAG_BACKPACK" diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiResponseModels.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiResponseModels.kt new file mode 100644 index 0000000..9416c46 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiResponseModels.kt @@ -0,0 +1,41 @@ +package com.github.worn.data.source.ai + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The JSON contract [AiPrompts] asks every provider for. Provider-neutral on purpose: these are + * shapes *we* define in the prompt, unlike the transport models in `ClaudeApiModels.kt` which + * are dictated by the Anthropic API. + */ +@Serializable +internal data class AiAnalysisJson( + val description: String, + @SerialName("suggested_category") val suggestedCategory: String, + val colors: List, + val seasons: List, + val tags: List, + @SerialName("suggested_subcategory") val suggestedSubcategory: String? = null, + @SerialName("suggested_fit") val suggestedFit: String? = null, + @SerialName("suggested_material") val suggestedMaterial: String? = null, +) + +@Serializable +internal data class GapRecommendationJson( + @SerialName("item_name") val itemName: String, + val category: String, + @SerialName("pairing_count") val pairingCount: Int, + val subcategory: String? = null, + val colors: List = emptyList(), + val seasons: List = emptyList(), + val fit: String? = null, + val material: String? = null, +) + +@Serializable +internal data class TryItResultJson( + @SerialName("matching_item_ids") val matchingItemIds: List, + @SerialName("combinations_unlocked") val combinationsUnlocked: Int, + @SerialName("gaps_filled") val gapsFilled: List, + @SerialName("worth_adding") val worthAdding: Boolean, +) diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiResponseParser.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiResponseParser.kt new file mode 100644 index 0000000..14b1d86 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/AiResponseParser.kt @@ -0,0 +1,92 @@ +package com.github.worn.data.source.ai + +import com.github.worn.domain.model.AiAnalysisResult +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.ClothingItem +import com.github.worn.domain.model.Fit +import com.github.worn.domain.model.GapRecommendation +import com.github.worn.domain.model.Material +import com.github.worn.domain.model.Season +import com.github.worn.domain.model.Subcategory +import com.github.worn.domain.model.TryItResult +import kotlinx.serialization.json.Json + +/** + * Turns a model's raw reply into domain models, for the cloud and on-device providers alike. + * + * Enum values are parsed leniently — a model that invents a category shouldn't fail the whole + * analysis, so unknown values become `null` (or the [Category.TOP] default) rather than throwing. + */ +internal object AiResponseParser { + + private val json = Json { ignoreUnknownKeys = true } + + fun parseAnalysis(responseText: String): AiAnalysisResult { + val parsed = json.decodeFromString(stripCodeFence(responseText)) + return AiAnalysisResult( + description = parsed.description, + suggestedCategory = parsed.suggestedCategory.toEnumOrNull() ?: Category.TOP, + colors = parsed.colors, + seasons = parsed.seasons.mapNotNull { it.toEnumOrNull() }, + tags = parsed.tags, + suggestedSubcategory = parsed.suggestedSubcategory?.toEnumOrNull(), + suggestedFit = parsed.suggestedFit?.toEnumOrNull(), + suggestedMaterial = parsed.suggestedMaterial?.toEnumOrNull(), + ) + } + + fun parseGaps(responseText: String): List = + json.decodeFromString>(stripCodeFence(responseText)) + .map { it.toDomain() } + + fun parseTryIt(responseText: String, existingItems: List): TryItResult { + val parsed = json.decodeFromString(stripCodeFence(responseText)) + return TryItResult( + matchingItems = parsed.matchingItemIds.mapNotNull { id -> + existingItems.find { it.id == id } + }, + combinationsUnlocked = parsed.combinationsUnlocked, + gapsFilled = parsed.gapsFilled, + worthAdding = parsed.worthAdding, + ) + } + + /** + * On-device models wrap JSON in a ```json fence despite being told not to. Unwrap it rather + * than failing the request; already-bare JSON passes through untouched. + */ + fun stripCodeFence(responseText: String): String { + val trimmed = responseText.trim() + if (!trimmed.startsWith("```")) return trimmed + return trimmed + .removePrefix("```") + .substringAfter('\n', missingDelimiterValue = "") + .substringBeforeLast("```") + .trim() + } +} + +internal fun GapRecommendationJson.toDomain(): GapRecommendation = GapRecommendation( + itemName = itemName, + category = category, + pairingCount = pairingCount, + subcategory = subcategory?.toEnumOrNull(), + colors = colors, + seasons = seasons.mapNotNull { it.toEnumOrNull() }, + fit = fit?.toEnumOrNull(), + material = material?.toEnumOrNull(), + mappedCategory = mapDisplayCategoryToCategory(category), +) + +private inline fun > String.toEnumOrNull(): T? = + runCatching { enumValueOf(trim().uppercase()) }.getOrNull() + +private fun mapDisplayCategoryToCategory(displayCategory: String): Category = + when (displayCategory.uppercase()) { + "BASICS", "TOPS" -> Category.TOP + "LAYERING" -> Category.OUTERWEAR + "BOTTOMS" -> Category.BOTTOM + "SHOES" -> Category.SHOES + "ACCESSORIES" -> Category.ACCESSORY + else -> Category.TOP + } diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiClient.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiClient.kt index 5b02e74..2efdc24 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiClient.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiClient.kt @@ -1,13 +1,11 @@ package com.github.worn.data.source.remote +import com.github.worn.data.source.ai.AiPrompts +import com.github.worn.data.source.ai.AiPrompts.toPromptContext +import com.github.worn.data.source.ai.AiResponseParser import com.github.worn.domain.model.AiAnalysisResult -import com.github.worn.domain.model.Category import com.github.worn.domain.model.ClothingItem -import com.github.worn.domain.model.Fit import com.github.worn.domain.model.GapRecommendation -import com.github.worn.domain.model.Material -import com.github.worn.domain.model.Season -import com.github.worn.domain.model.Subcategory import com.github.worn.domain.model.TryItResult import com.github.worn.domain.model.UserProfile import com.github.worn.util.secret.SecretStore @@ -37,55 +35,23 @@ class ClaudeApiClient( suspend fun analyzeImage(imageBytes: ByteArray): AiAnalysisResult { val responseText = sendRequest( - systemPrompt = ANALYZE_SYSTEM_PROMPT, + systemPrompt = AiPrompts.ANALYZE_SYSTEM_PROMPT, imageBytes = imageBytes, userText = "Analyze this clothing item image.", ) - val parsed = json.decodeFromString(responseText) - return AiAnalysisResult( - description = parsed.description, - suggestedCategory = runCatching { - Category.valueOf(parsed.suggestedCategory.uppercase()) - }.getOrDefault(Category.TOP), - colors = parsed.colors, - seasons = parsed.seasons.mapNotNull { - runCatching { Season.valueOf(it.uppercase()) }.getOrNull() - }, - tags = parsed.tags, - suggestedSubcategory = parsed.suggestedSubcategory?.let { - runCatching { Subcategory.valueOf(it.uppercase()) }.getOrNull() - }, - suggestedFit = parsed.suggestedFit?.let { - runCatching { Fit.valueOf(it.uppercase()) }.getOrNull() - }, - suggestedMaterial = parsed.suggestedMaterial?.let { - runCatching { Material.valueOf(it.uppercase()) }.getOrNull() - }, - ) + return AiResponseParser.parseAnalysis(responseText) } suspend fun getGapRecommendations( items: List, userProfile: UserProfile? = null, ): List { - val wardrobeSummary = items.joinToString("\n") { item -> - buildString { - append("- ${item.name} (${item.category}") - item.subcategory?.let { append(", type: $it") } - append(", colors: ${item.colors.joinToString()}") - append(", seasons: ${item.seasons.joinToString()}") - item.fit?.let { append(", fit: $it") } - item.material?.let { append(", material: $it") } - append(")") - } - } val profileContext = userProfile?.toPromptContext() ?: "" val responseText = sendRequest( - systemPrompt = GAPS_SYSTEM_PROMPT, - userText = "${profileContext}My wardrobe:\n$wardrobeSummary", + systemPrompt = AiPrompts.GAPS_SYSTEM_PROMPT, + userText = "${profileContext}My wardrobe:\n${AiPrompts.wardrobeSummary(items)}", ) - val parsed = json.decodeFromString>(responseText) - return parsed.map { it.toDomain() } + return AiResponseParser.parseGaps(responseText) } suspend fun analyzeProspectiveItem( @@ -93,33 +59,14 @@ class ClaudeApiClient( existingItems: List, userProfile: UserProfile? = null, ): TryItResult { - val wardrobeSummary = existingItems.joinToString("\n") { item -> - buildString { - append("- [${item.id}] ${item.name} (${item.category}") - item.subcategory?.let { append(", type: $it") } - append(", colors: ${item.colors.joinToString()}") - append(", seasons: ${item.seasons.joinToString()}") - item.fit?.let { append(", fit: $it") } - item.material?.let { append(", material: $it") } - append(")") - } - } + val wardrobeSummary = AiPrompts.wardrobeSummary(existingItems, includeIds = true) val profileContext = userProfile?.toPromptContext() ?: "" val responseText = sendRequest( - systemPrompt = TRY_IT_SYSTEM_PROMPT, + systemPrompt = AiPrompts.TRY_IT_SYSTEM_PROMPT, imageBytes = imageBytes, userText = "${profileContext}Would this item fit my wardrobe?\n\nMy wardrobe:\n$wardrobeSummary", ) - val parsed = json.decodeFromString(responseText) - val matchingItems = parsed.matchingItemIds.mapNotNull { id -> - existingItems.find { it.id == id } - } - return TryItResult( - matchingItems = matchingItems, - combinationsUnlocked = parsed.combinationsUnlocked, - gapsFilled = parsed.gapsFilled, - worthAdding = parsed.worthAdding, - ) + return AiResponseParser.parseTryIt(responseText, existingItems) } @OptIn(ExperimentalEncodingApi::class) @@ -209,87 +156,5 @@ class ClaudeApiClient( private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_TOO_MANY_REQUESTS = 429 private val HTTP_SERVER_ERROR_RANGE = 500..599 - - private val ANALYZE_SYSTEM_PROMPT = """ - You are a men's fashion analysis AI specialized in capsule wardrobe building. - Analyze the clothing item in the image. - Respond with ONLY a JSON object (no markdown): - { - "description": "brief description of the item", - "suggested_category": "one of: TOP, BOTTOM, OUTERWEAR, SHOES, ACCESSORY", - "colors": ["color1", "color2"], - "seasons": ["one or more of: SPRING, SUMMER, FALL, WINTER"], - "tags": ["tag1", "tag2", "tag3"], - "suggested_subcategory": "one of: T_SHIRT, POLO, DRESS_SHIRT, HENLEY, SWEATER, HOODIE, JEANS, CHINOS, TAILORED_PANTS, SHORTS, CARGO_PANTS, SWEATPANTS, BOMBER, TRUCKER, PUFFER, BLAZER, COAT, WINDBREAKER, SNEAKERS, BOOTS_MILITARY, BOOTS_CHELSEA, DERBY, OXFORD, LOAFER, SANDALS, WATCH, BELT, SUNGLASSES, HAT_CAP, SCARF, BAG_BACKPACK", - "suggested_fit": "one of: SLIM_FIT, REGULAR, RELAXED, OVERSIZED", - "suggested_material": "one of: COTTON, LINEN, DENIM, WOOL, SYNTHETIC, LEATHER, SILK, KNIT" - } - """.trimIndent() - - private val GAPS_SYSTEM_PROMPT = """ - You are a men's capsule wardrobe analysis AI. Given a user's wardrobe, suggest - versatile items that would maximize outfit combinations following capsule wardrobe - principles. Prioritize timeless, mix-and-match pieces over trendy items. - Group suggestions by category (BASICS, LAYERING, BOTTOMS, SHOES, ACCESSORIES). - Respond with ONLY a JSON array (no markdown): - [{"item_name": "...", "category": "...", "pairing_count": N, - "subcategory": "one of: T_SHIRT, POLO, DRESS_SHIRT, HENLEY, SWEATER, HOODIE, JEANS, CHINOS, TAILORED_PANTS, SHORTS, CARGO_PANTS, SWEATPANTS, BOMBER, TRUCKER, PUFFER, BLAZER, COAT, WINDBREAKER, SNEAKERS, BOOTS_MILITARY, BOOTS_CHELSEA, DERBY, OXFORD, LOAFER, SANDALS, WATCH, BELT, SUNGLASSES, HAT_CAP, SCARF, BAG_BACKPACK", - "colors": ["color1"], - "seasons": ["SPRING", "SUMMER", "FALL", "WINTER"], - "fit": "one of: SLIM_FIT, REGULAR, RELAXED, OVERSIZED", - "material": "one of: COTTON, LINEN, DENIM, WOOL, SYNTHETIC, LEATHER, SILK, KNIT"}] - """.trimIndent() - - private val TRY_IT_SYSTEM_PROMPT = """ - You are a men's capsule wardrobe analysis AI. Given a photo of a prospective - clothing item and the user's existing wardrobe, evaluate how well this item - contributes to versatility and outfit combinations following capsule wardrobe - principles. - Respond with ONLY a JSON object (no markdown): - { - "matching_item_ids": ["id1", "id2"], - "combinations_unlocked": N, - "gaps_filled": ["gap description 1", "gap description 2"], - "worth_adding": true/false - } - """.trimIndent() - } -} - -internal fun GapRecommendationJson.toDomain(): GapRecommendation = GapRecommendation( - itemName = itemName, - category = category, - pairingCount = pairingCount, - subcategory = subcategory?.let { runCatching { Subcategory.valueOf(it.uppercase()) }.getOrNull() }, - colors = colors, - seasons = seasons.mapNotNull { runCatching { Season.valueOf(it.uppercase()) }.getOrNull() }, - fit = fit?.let { runCatching { Fit.valueOf(it.uppercase()) }.getOrNull() }, - material = material?.let { runCatching { Material.valueOf(it.uppercase()) }.getOrNull() }, - mappedCategory = mapDisplayCategoryToCategory(category), -) - -private fun mapDisplayCategoryToCategory(displayCategory: String): Category = - when (displayCategory.uppercase()) { - "BASICS", "TOPS" -> Category.TOP - "LAYERING" -> Category.OUTERWEAR - "BOTTOMS" -> Category.BOTTOM - "SHOES" -> Category.SHOES - "ACCESSORIES" -> Category.ACCESSORY - else -> Category.TOP - } - -private fun UserProfile.toPromptContext(): String { - val parts = mutableListOf() - bodyType?.let { parts.add("Body type: ${it.name.lowercase().replace('_', ' ')}") } - styleProfile?.let { parts.add("Style: ${it.name.lowercase().replace('_', ' ')}") } - ageRange?.let { - parts.add( - "Age range: ${it.name.removePrefix("AGE_").replace('_', '-').replace("PLUS", "+")}", - ) - } - climate?.let { parts.add("Climate: ${it.name.lowercase()}") } - if (lifestyles.isNotEmpty()) { - parts.add("Lifestyle: ${lifestyles.joinToString { it.name.lowercase().replace('_', ' ') }}") } - return if (parts.isEmpty()) "" else "User profile:\n${parts.joinToString("\n")}\n\n" } diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiModels.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiModels.kt index 50499a3..53c7629 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiModels.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiModels.kt @@ -53,38 +53,6 @@ internal data class ClaudeResponseContent( val text: String? = null, ) -@Serializable -internal data class AiAnalysisJson( - val description: String, - @SerialName("suggested_category") val suggestedCategory: String, - val colors: List, - val seasons: List, - val tags: List, - @SerialName("suggested_subcategory") val suggestedSubcategory: String? = null, - @SerialName("suggested_fit") val suggestedFit: String? = null, - @SerialName("suggested_material") val suggestedMaterial: String? = null, -) - -@Serializable -internal data class GapRecommendationJson( - @SerialName("item_name") val itemName: String, - val category: String, - @SerialName("pairing_count") val pairingCount: Int, - val subcategory: String? = null, - val colors: List = emptyList(), - val seasons: List = emptyList(), - val fit: String? = null, - val material: String? = null, -) - -@Serializable -internal data class TryItResultJson( - @SerialName("matching_item_ids") val matchingItemIds: List, - @SerialName("combinations_unlocked") val combinationsUnlocked: Int, - @SerialName("gaps_filled") val gapsFilled: List, - @SerialName("worth_adding") val worthAdding: Boolean, -) - @Serializable internal data class ClaudeErrorResponse( val type: String, From 49226184522f3fdf307c75dabb46ff4acedde2d0 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 27 Jul 2026 20:18:11 -0300 Subject: [PATCH 2/2] feat: run photo analysis and gaps on the device's own AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "On-device AI" toggle to Settings, disabled with an explanatory subtitle when the device cannot provide a local model. When on, photo analysis and gap recommendations run through Gemini Nano (ML Kit GenAI) on Android and Apple Intelligence (FoundationModels) on iOS, so users without a Claude key get AI features and their photos never leave the device. The engine is an interface with per-platform implementations bound in Koin rather than an expect class, so the shared prompt and parsing logic stays testable against a fake. Provider choice lives in WardrobeRepositoryImpl per ARCHITECTURE.md. There is deliberately no fallback to Claude when a local call fails: opting in has to mean nothing is uploaded. FoundationModels is Swift-only and unreachable from Kotlin/Native interop, so iOS goes through a Swift bridge registered at launch. It needs iOS 26 while the app deploys to 18.2, hence #available guards plus weak linking. Try-It still uses Claude — it reasons over the whole wardrobe against a new photo, which a small local model handles poorly. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/github/worn/ui/screen/AddItemSheet.kt | 8 +- .../com/github/worn/ui/screen/GapsScreen.kt | 8 +- .../github/worn/ui/screen/SettingsScreen.kt | 105 +++++++++- .../github/worn/ui/screen/WardrobeScreen.kt | 2 +- .../src/main/res/values-pt-rBR/strings.xml | 9 +- composeApp/src/main/res/values/strings.xml | 9 +- gradle/libs.versions.toml | 2 + iosApp/Configuration/Config.xcconfig | 7 +- iosApp/iosApp/Screens/AddItemSheet.swift | 8 +- iosApp/iosApp/Screens/GapsScreen.swift | 2 +- iosApp/iosApp/Screens/SettingsScreen.swift | 72 +++++++ iosApp/iosApp/Screens/WardrobeScreen.swift | 2 +- .../iosApp/Services/OnDeviceAiService.swift | 97 +++++++++ .../ViewModels/SettingsViewModelWrapper.swift | 4 + iosApp/iosApp/en.lproj/Localizable.strings | 9 +- iosApp/iosApp/iOSApp.swift | 3 + iosApp/iosApp/pt-BR.lproj/Localizable.strings | 9 +- shared/build.gradle.kts | 1 + .../repository/WardrobeRepositoryImplTest.kt | 116 ++++++++++- .../data/source/ai/AndroidOnDeviceAiEngine.kt | 119 +++++++++++ .../com/github/worn/di/AndroidModule.kt | 3 + .../data/repository/SettingsRepositoryImpl.kt | 25 +++ .../data/repository/WardrobeRepositoryImpl.kt | 25 ++- .../worn/data/source/ai/OnDeviceAiEngine.kt | 32 +++ .../worn/data/source/ai/OnDeviceAiSource.kt | 39 ++++ .../kotlin/com/github/worn/di/SharedModule.kt | 4 + .../domain/model/OnDeviceAiAvailability.kt | 35 ++++ .../domain/repository/SettingsRepository.kt | 14 ++ .../presentation/viewmodel/GapsViewModel.kt | 9 +- .../viewmodel/SettingsViewModel.kt | 40 +++- .../viewmodel/WardrobeViewModel.kt | 11 +- .../github/worn/ai/OnDeviceAiSourceTest.kt | 186 ++++++++++++++++++ .../github/worn/fake/FakeOnDeviceAiEngine.kt | 38 ++++ .../worn/fake/FakeSettingsRepository.kt | 20 ++ .../worn/viewmodel/SettingsViewModelTest.kt | 156 +++++++++++++++ .../worn/viewmodel/WardrobeViewModelTest.kt | 30 ++- .../data/source/ai/IosOnDeviceAiEngine.kt | 62 ++++++ .../worn/data/source/ai/OnDeviceAiBridge.kt | 48 +++++ .../kotlin/com/github/worn/di/IosModule.kt | 3 + 39 files changed, 1333 insertions(+), 39 deletions(-) create mode 100644 iosApp/iosApp/Services/OnDeviceAiService.swift create mode 100644 shared/src/androidMain/kotlin/com/github/worn/data/source/ai/AndroidOnDeviceAiEngine.kt create mode 100644 shared/src/commonMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiEngine.kt create mode 100644 shared/src/commonMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiSource.kt create mode 100644 shared/src/commonMain/kotlin/com/github/worn/domain/model/OnDeviceAiAvailability.kt create mode 100644 shared/src/commonTest/kotlin/com/github/worn/ai/OnDeviceAiSourceTest.kt create mode 100644 shared/src/commonTest/kotlin/com/github/worn/fake/FakeOnDeviceAiEngine.kt create mode 100644 shared/src/commonTest/kotlin/com/github/worn/viewmodel/SettingsViewModelTest.kt create mode 100644 shared/src/iosMain/kotlin/com/github/worn/data/source/ai/IosOnDeviceAiEngine.kt create mode 100644 shared/src/iosMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiBridge.kt diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt index fdb0a29..df5b876 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt @@ -86,7 +86,7 @@ import java.io.ByteArrayOutputStream @Composable fun AddItemSheet( isSaving: Boolean, - hasApiKey: Boolean, + isAiAvailable: Boolean, existingItem: ClothingItem? = null, onSave: ( imageBytes: ByteArray, name: String, category: Category, @@ -106,7 +106,7 @@ fun AddItemSheet( ) { AddItemForm( isSaving = isSaving, - hasApiKey = hasApiKey, + isAiAvailable = isAiAvailable, existingItem = existingItem, onSave = onSave, ) @@ -116,7 +116,7 @@ fun AddItemSheet( @Composable internal fun AddItemForm( isSaving: Boolean = false, - hasApiKey: Boolean = false, + isAiAvailable: Boolean = false, existingItem: ClothingItem? = null, onSave: (ByteArray, String, Category, List, List, Subcategory?, Fit?, Material?) -> Unit = { _, _, _, _, _, _, _, _ -> }, @@ -215,7 +215,7 @@ internal fun AddItemForm( canSave = formState.hasPhoto && formState.name.isNotBlank() && formState.selectedCategory != null, isEditing = existingItem != null, onPhotoClick = { formState.showSourceChooser = true }, - onAiBadgeClick = { if (!hasApiKey) formState.showAiLockedSheet = true }, + onAiBadgeClick = { if (!isAiAvailable) formState.showAiLockedSheet = true }, onSave = { val cat = formState.selectedCategory ?: return@AddItemFormContent val bytes = formState.photoBytes ?: ByteArray(0) diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt index 0fca492..31eb0a4 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt @@ -123,7 +123,7 @@ fun GapsScreen(onTabSelected: (Tab) -> Unit) { val gap = addItemPreFill!! AddItemSheet( isSaving = false, - hasApiKey = state.hasApiKey, + isAiAvailable = state.isAiAvailable, existingItem = gap.toPreFilledItem(), onSave = { _, _, _, _, _, _, _, _ -> showAddItemSheet = false }, onDismiss = { showAddItemSheet = false }, @@ -613,7 +613,7 @@ private fun GapsScreenPhonePreview() { state = GapsState( recommendations = com.github.worn.domain.model.capsuleWardrobeSuggestions.take(6), isAiMode = false, - hasApiKey = false, + isAiAvailable = false, ), ) } @@ -627,7 +627,7 @@ private fun GapsScreenTabletPreview() { state = GapsState( recommendations = com.github.worn.domain.model.capsuleWardrobeSuggestions.take(6), isAiMode = true, - hasApiKey = true, + isAiAvailable = true, ), isCompact = false, ) @@ -650,7 +650,7 @@ private fun GapsScreenErrorPreview() { state = GapsState( error = "Invalid API key. Check your key in Settings.", isAiMode = true, - hasApiKey = true, + isAiAvailable = true, ), ) } diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt index 96f6ded..3309357 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Checkroom import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.PhoneAndroid import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.ExperimentalMaterial3Api @@ -34,6 +35,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults @@ -58,6 +61,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.annotation.StringRes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -69,8 +73,11 @@ import com.github.worn.domain.model.AgeRange import com.github.worn.domain.model.BodyType import com.github.worn.domain.model.Climate import com.github.worn.domain.model.Lifestyle +import com.github.worn.domain.model.OnDeviceAiAvailability +import com.github.worn.domain.model.OnDeviceAiUnavailableReason import com.github.worn.domain.model.StyleProfile import com.github.worn.domain.model.UserProfile +import com.github.worn.domain.model.isUsable import com.github.worn.presentation.viewmodel.SettingsEffect import com.github.worn.presentation.viewmodel.SettingsIntent import com.github.worn.presentation.viewmodel.SettingsState @@ -111,6 +118,7 @@ fun SettingsScreen(onTabSelected: (Tab) -> Unit) { onProfileClick = { showProfileSheet = true }, onApiKeyClick = { showApiKeySheet = true }, onYouCamClick = { showYouCamSheet = true }, + onOnDeviceAiChange = { viewModel.onIntent(SettingsIntent.SetOnDeviceAi(it)) }, ) if (showProfileSheet) { @@ -161,6 +169,7 @@ private fun SettingsScaffold( onProfileClick: () -> Unit = {}, onApiKeyClick: () -> Unit = {}, onYouCamClick: () -> Unit = {}, + onOnDeviceAiChange: (Boolean) -> Unit = {}, ) { val contentPadding = if (isCompact) 24.dp else 32.dp @@ -198,6 +207,17 @@ private fun SettingsScaffold( Spacer(Modifier.height(24.dp)) SectionLabel(stringResource(R.string.settings_section_ai)) Spacer(Modifier.height(10.dp)) + // Listed before the key card: it is free and private, so it should be the first option. + SettingsToggleCard( + icon = { SettingsIcon(color = WornColors.AccentGreen, icon = Icons.Outlined.PhoneAndroid) }, + title = stringResource(R.string.settings_on_device_ai_title), + subtitle = stringResource(state.onDeviceAiAvailability.subtitleRes()), + checked = state.onDeviceAiEnabled, + enabled = state.onDeviceAiAvailability.isUsable, + onCheckedChange = onOnDeviceAiChange, + modifier = Modifier.testTag("settings_on_device_ai_toggle"), + ) + Spacer(Modifier.height(10.dp)) SettingsCard( icon = { SettingsIcon(color = WornColors.AccentIndigo, icon = Icons.Outlined.AutoAwesome) }, title = stringResource(R.string.settings_api_key_title), @@ -260,6 +280,67 @@ private fun SettingsIcon(color: Color, icon: androidx.compose.ui.graphics.vector } } +/** + * A [SettingsCard] whose trailing affordance is a switch instead of a chevron. The whole row is + * tappable so the target matches the other cards, and the switch is disabled — rather than hidden — + * when the capability is missing, so the subtitle can explain why. + */ +@Composable +private fun SettingsToggleCard( + icon: @Composable () -> Unit, + title: String, + subtitle: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = { onCheckedChange(!checked) }, + enabled = enabled, + shape = RoundedCornerShape(16.dp), + color = WornColors.BgCard, + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + icon() + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, color = WornColors.TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.Medium) + Text(subtitle, color = WornColors.TextSecondary, fontSize = 13.sp) + } + Spacer(Modifier.width(8.dp)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = WornColors.AccentGreen, + ), + ) + } + } +} + +@StringRes +private fun OnDeviceAiAvailability.subtitleRes(): Int = when (this) { + OnDeviceAiAvailability.Available -> R.string.settings_on_device_ai_available + OnDeviceAiAvailability.Downloadable -> R.string.settings_on_device_ai_downloading + is OnDeviceAiAvailability.Unavailable -> when (reason) { + OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE -> + R.string.settings_on_device_ai_unsupported_device + OnDeviceAiUnavailableReason.UNSUPPORTED_OS -> + R.string.settings_on_device_ai_unsupported_os + OnDeviceAiUnavailableReason.DISABLED_BY_USER -> + R.string.settings_on_device_ai_disabled_by_user + OnDeviceAiUnavailableReason.UNKNOWN -> R.string.settings_on_device_ai_unavailable + } +} + @Composable private fun SettingsCard( icon: @Composable () -> Unit, @@ -919,7 +1000,12 @@ private const val LICENSE_URL = "https://github.com/jvsena42/worn/blob/main/LICE @Composable private fun SettingsScreenPhonePreview() { WornTheme { - SettingsScaffold(state = SettingsState()) + SettingsScaffold( + state = SettingsState( + onDeviceAiEnabled = true, + onDeviceAiAvailability = OnDeviceAiAvailability.Available, + ), + ) } } @@ -927,6 +1013,21 @@ private fun SettingsScreenPhonePreview() { @Composable private fun SettingsScreenTabletPreview() { WornTheme { - SettingsScaffold(state = SettingsState(), isCompact = false) + SettingsScaffold( + state = SettingsState( + onDeviceAiEnabled = true, + onDeviceAiAvailability = OnDeviceAiAvailability.Available, + ), + isCompact = false, + ) + } +} + +/** The default [SettingsState] already reports on-device AI as unavailable. */ +@Preview(showSystemUi = true, device = "id:pixel_8") +@Composable +private fun SettingsScreenOnDeviceAiUnavailablePreview() { + WornTheme { + SettingsScaffold(state = SettingsState()) } } diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt index 3e71449..21b5a95 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt @@ -165,7 +165,7 @@ private fun WardrobeAddItemSheet( ) { AddItemSheet( isSaving = state.isSaving, - hasApiKey = state.hasApiKey, + isAiAvailable = state.isAiAvailable, existingItem = editItem, onSave = { imageBytes, name, category, colors, seasons, subcategory, fit, material -> if (editItem != null) { diff --git a/composeApp/src/main/res/values-pt-rBR/strings.xml b/composeApp/src/main/res/values-pt-rBR/strings.xml index 83d8e8e..a9b6984 100644 --- a/composeApp/src/main/res/values-pt-rBR/strings.xml +++ b/composeApp/src/main/res/values-pt-rBR/strings.xml @@ -88,6 +88,13 @@ Seu Perfil Toque para configurar Ajude a IA a dar melhores sugestões + IA no dispositivo + Roda de forma privada neste aparelho, sem chave + O modelo é baixado no primeiro uso + Não compatível com este aparelho + Requer uma versão mais recente do Android + Ative o Gemini Nano nos ajustes do sistema + Indisponível no momento Chave da API Claude Conectado Necessária para funções de IA @@ -173,7 +180,7 @@ Desbloquear funções de IA - Adicione sua chave da API Claude nos Ajustes para habilitar. + Ative a IA no dispositivo ou adicione sua chave da API Claude nos Ajustes. Ir para Ajustes diff --git a/composeApp/src/main/res/values/strings.xml b/composeApp/src/main/res/values/strings.xml index 7915685..8cfaba5 100644 --- a/composeApp/src/main/res/values/strings.xml +++ b/composeApp/src/main/res/values/strings.xml @@ -88,6 +88,13 @@ Your Profile Tap to set up Help AI give better suggestions + On-device AI + Runs privately on this device, no key needed + The model downloads the first time you use it + Not supported by this device + Needs a newer Android version + Turn on Gemini Nano in system settings + Unavailable right now Claude API Key Connected Required for AI features @@ -173,7 +180,7 @@ Unlock AI features - Add your Claude API key in Settings to enable this. + Turn on on-device AI or add your Claude API key in Settings. Go to Settings diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ede240..2f4b552 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ turbine = "1.2.1" mockk = "1.14.11" datastore = "1.2.1" splashscreen = "1.2.0" +mlkitGenaiPrompt = "1.0.0-beta2" mlkitSubjectSegmentation = "16.0.0-beta1" [libraries] @@ -64,6 +65,7 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" } datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" } +mlkit-genai-prompt = { module = "com.google.mlkit:genai-prompt", version.ref = "mlkitGenaiPrompt" } mlkit-subject-segmentation = { module = "com.google.android.gms:play-services-mlkit-subject-segmentation", version.ref = "mlkitSubjectSegmentation" } [plugins] diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index e3bb377..72e4500 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -4,4 +4,9 @@ PRODUCT_NAME=Worn PRODUCT_BUNDLE_IDENTIFIER=com.github.worn.Worn$(TEAM_ID) CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 \ No newline at end of file +MARKETING_VERSION=1.0 + +// FoundationModels (Apple Intelligence) only exists from iOS 26, but the app deploys back to +// 18.2. Weak linking lets it launch on older systems, where OnDeviceAiService reports the +// feature as unavailable via its #available guard. +OTHER_LDFLAGS=$(inherited) -weak_framework FoundationModels \ No newline at end of file diff --git a/iosApp/iosApp/Screens/AddItemSheet.swift b/iosApp/iosApp/Screens/AddItemSheet.swift index 27248b6..a881b24 100644 --- a/iosApp/iosApp/Screens/AddItemSheet.swift +++ b/iosApp/iosApp/Screens/AddItemSheet.swift @@ -4,7 +4,7 @@ import Shared struct AddItemSheet: View { let isSaving: Bool - let hasApiKey: Bool + let isAiAvailable: Bool var existingItem: ClothingItem? let onSave: (Data, String, Category, [String], [Season], Subcategory?, Fit?, Material?) -> Void let onDismiss: () -> Void @@ -238,7 +238,7 @@ struct AddItemSheet: View { private var aiBadge: some View { Button { - if !hasApiKey { showAiLockedSheet = true } + if !isAiAvailable { showAiLockedSheet = true } } label: { HStack(spacing: 6) { Text("✦") @@ -565,10 +565,10 @@ struct AddItemSheet: View { } #Preview("iPhone") { - AddItemSheet(isSaving: false, hasApiKey: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) + AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) } #Preview("iPad Portrait") { - AddItemSheet(isSaving: false, hasApiKey: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) + AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Screens/GapsScreen.swift b/iosApp/iosApp/Screens/GapsScreen.swift index 6297a7a..8ae69a8 100644 --- a/iosApp/iosApp/Screens/GapsScreen.swift +++ b/iosApp/iosApp/Screens/GapsScreen.swift @@ -72,7 +72,7 @@ struct GapsScreen: View { if let gap = addItemPreFill { AddItemSheet( isSaving: false, - hasApiKey: viewModel.state.hasApiKey, + isAiAvailable: viewModel.state.isAiAvailable, existingItem: gap.toPreFilledItem(), onSave: { _, _, _, _, _, _, _, _ in showAddItemSheet = false }, onDismiss: { showAddItemSheet = false } diff --git a/iosApp/iosApp/Screens/SettingsScreen.swift b/iosApp/iosApp/Screens/SettingsScreen.swift index 5e6b3fa..c24e1f6 100644 --- a/iosApp/iosApp/Screens/SettingsScreen.swift +++ b/iosApp/iosApp/Screens/SettingsScreen.swift @@ -32,6 +32,22 @@ struct SettingsScreen: View { sectionLabel(String(localized: "settings_section_ai")) .padding(.top, 24) + // Listed before the key card: it is free and private, so it should be the + // first option a new user sees. + settingsToggleCard( + iconColor: WornColors.accentGreen, + iconName: "iphone", + title: String(localized: "settings_on_device_ai_title"), + subtitle: onDeviceAiSubtitle, + isOn: Binding( + get: { viewModel.state.onDeviceAiEnabled }, + set: { viewModel.setOnDeviceAi($0) } + ), + enabled: viewModel.state.onDeviceAiAvailability.isUsable + ) + .padding(.top, 10) + .accessibilityIdentifier("settings_on_device_ai_toggle") + settingsCard( iconColor: WornColors.accentIndigo, iconName: "sparkles", @@ -94,6 +110,24 @@ struct SettingsScreen: View { } } + private var onDeviceAiSubtitle: String { + switch viewModel.state.onDeviceAiAvailability { + case is OnDeviceAiAvailability.Available: + return String(localized: "settings_on_device_ai_available") + case is OnDeviceAiAvailability.Downloadable: + return String(localized: "settings_on_device_ai_downloading") + case let unavailable as OnDeviceAiAvailability.Unavailable: + switch unavailable.reason { + case .unsupportedDevice: return String(localized: "settings_on_device_ai_unsupported_device") + case .unsupportedOs: return String(localized: "settings_on_device_ai_unsupported_os") + case .disabledByUser: return String(localized: "settings_on_device_ai_disabled_by_user") + default: return String(localized: "settings_on_device_ai_unavailable") + } + default: + return String(localized: "settings_on_device_ai_unavailable") + } + } + private var profileSummary: String { let profile = viewModel.state.userProfile let parts: [String] = [ @@ -144,6 +178,44 @@ struct SettingsScreen: View { .buttonStyle(.plain) } + /// A `settingsCard` whose trailing affordance is a switch instead of a chevron. The switch is + /// disabled — rather than hidden — when the capability is missing, so the subtitle can say why. + private func settingsToggleCard( + iconColor: Color, + iconName: String, + title: String, + subtitle: String, + isOn: Binding, + enabled: Bool + ) -> some View { + HStack(spacing: 14) { + RoundedRectangle(cornerRadius: 12) + .fill(iconColor) + .frame(width: 40, height: 40) + .overlay( + Image(systemName: iconName) + .font(.system(size: 18)) + .foregroundColor(.white) + ) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(WornColors.textPrimary) + Text(subtitle) + .font(.system(size: 13)) + .foregroundColor(WornColors.textSecondary) + } + Spacer() + Toggle("", isOn: isOn) + .labelsHidden() + .tint(WornColors.accentGreen) + .disabled(!enabled) + } + .padding(16) + .background(WornColors.bgCard) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" } diff --git a/iosApp/iosApp/Screens/WardrobeScreen.swift b/iosApp/iosApp/Screens/WardrobeScreen.swift index df34c9f..21b3eee 100644 --- a/iosApp/iosApp/Screens/WardrobeScreen.swift +++ b/iosApp/iosApp/Screens/WardrobeScreen.swift @@ -26,7 +26,7 @@ struct WardrobeScreen: View { .sheet(isPresented: $showAddSheet) { AddItemSheet( isSaving: viewModel.state.isSaving, - hasApiKey: viewModel.state.hasApiKey, + isAiAvailable: viewModel.state.isAiAvailable, existingItem: editItem, onSave: { data, name, category, colors, seasons, subcategory, fit, material in if let existing = editItem { diff --git a/iosApp/iosApp/Services/OnDeviceAiService.swift b/iosApp/iosApp/Services/OnDeviceAiService.swift new file mode 100644 index 0000000..40af0d9 --- /dev/null +++ b/iosApp/iosApp/Services/OnDeviceAiService.swift @@ -0,0 +1,97 @@ +import Foundation +import Shared +import UIKit + +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// Apple Intelligence implementation of the shared `OnDeviceAiBridge`. +/// +/// This lives in Swift rather than `iosMain` because `FoundationModels` exposes no Objective-C +/// interface, and Kotlin/Native interop only reaches C and Objective-C. Everything else about the +/// on-device provider — prompts, JSON parsing, routing — stays in shared Kotlin. +/// +/// The framework needs iOS 26 while the app deploys back to 18.2, so every use is behind +/// `#available` and the framework is weak-linked (see `Config.xcconfig`). +/// +/// Error messages are plain English here to match `AndroidOnDeviceAiEngine` and `ClaudeApiClient`, +/// which also raise unlocalized messages from the data layer. +final class OnDeviceAiService: NSObject, OnDeviceAiBridge { + + func availability(onResult: @escaping (OnDeviceAiAvailabilityToken) -> Void) { + #if canImport(FoundationModels) + guard #available(iOS 26, *) else { + onResult(.unsupportedOs) + return + } + switch SystemLanguageModel.default.availability { + case .available: + onResult(.available) + case .unavailable(.deviceNotEligible): + onResult(.unsupportedDevice) + case .unavailable(.appleIntelligenceNotEnabled): + onResult(.disabledByUser) + case .unavailable(.modelNotReady): + onResult(.downloadable) + case .unavailable: + onResult(.unknown) + } + #else + onResult(.unsupportedOs) + #endif + } + + func generate( + systemPrompt: String, + userText: String, + imageBytes: KotlinByteArray?, + onResult: @escaping (String?, String?) -> Void + ) { + #if canImport(FoundationModels) + guard #available(iOS 26, *) else { + onResult(nil, Self.unsupportedMessage) + return + } + let image = imageBytes.flatMap { UIImage(data: $0.toData()) } + if imageBytes != nil && image == nil { + onResult(nil, "Could not read the photo. Please try again.") + return + } + Task { + do { + let session = LanguageModelSession(instructions: systemPrompt) + let content: String + if let image { + content = try await session.respond( + to: Prompt { + userText + Attachment(ImageAttachmentContent(image)) + } + ).content + } else { + content = try await session.respond(to: userText).content + } + onResult(content, nil) + } catch { + onResult(nil, error.localizedDescription) + } + } + #else + onResult(nil, Self.unsupportedMessage) + #endif + } + + private static let unsupportedMessage = + "On-device AI isn't available on this device. Turn it off in Settings." +} + +private extension KotlinByteArray { + func toData() -> Data { + var data = Data(count: Int(size)) + for index in 0..(relaxed = true) private val fileStorage = mockk() private val aiClient = mockk() + private val onDeviceAi = mockk() + private val onDeviceAiEnabled = MutableStateFlow(false) private val settingsRepository = mockk { every { getUserProfile() } returns flowOf(UserProfile()) + every { isOnDeviceAiEnabled() } returns onDeviceAiEnabled } private lateinit var repository: WardrobeRepositoryImpl @@ -73,7 +78,9 @@ class WardrobeRepositoryImplTest { val tx = mockk(relaxed = true) body(tx) } - repository = WardrobeRepositoryImpl(db, fileStorage, aiClient, settingsRepository, testDispatcher) + onDeviceAiEnabled.value = false + repository = + WardrobeRepositoryImpl(db, fileStorage, aiClient, onDeviceAi, settingsRepository, testDispatcher) } // region getAll @@ -260,6 +267,46 @@ class WardrobeRepositoryImplTest { } } + @Test + fun `analyzeAndTag routes to the on-device engine when the preference is on`() = runTest { + onDeviceAiEnabled.value = true + val query = mockk>() + every { queries.getById("item-1") } returns query + every { query.executeAsOneOrNull() } returns dbItem + coEvery { fileStorage.read("/photos/item-1.jpg") } returns byteArrayOf(10, 20) + coEvery { onDeviceAi.analyzeImage(byteArrayOf(10, 20)) } returns AiAnalysisResult( + description = "Local analysis", + suggestedCategory = Category.TOP, + colors = listOf("grey"), + seasons = listOf(Season.WINTER), + tags = emptyList(), + ) + + val result = repository.analyzeAndTag("item-1") + + assertEquals("Local analysis", result.getOrThrow().description) + coVerify { onDeviceAi.analyzeImage(byteArrayOf(10, 20)) } + coVerify(exactly = 0) { aiClient.analyzeImage(any()) } + } + + /** Opting in means photos never leave the device, so a local failure must not retry on Claude. */ + @Test + fun `analyzeAndTag surfaces on-device failures without falling back to Claude`() = runTest { + onDeviceAiEnabled.value = true + val query = mockk>() + every { queries.getById("item-1") } returns query + every { query.executeAsOneOrNull() } returns dbItem + coEvery { fileStorage.read("/photos/item-1.jpg") } returns byteArrayOf(10, 20) + coEvery { onDeviceAi.analyzeImage(any()) } throws IllegalStateException("Model unavailable") + + val result = repository.analyzeAndTag("item-1") + + assertTrue(result.isFailure) + assertEquals("Model unavailable", result.exceptionOrNull()?.message) + coVerify(exactly = 0) { aiClient.analyzeImage(any()) } + verify(exactly = 0) { queries.update(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } + } + @Test fun `analyzeAndTag fails when item not found`() = runTest { val query = mockk>() @@ -345,4 +392,71 @@ class WardrobeRepositoryImplTest { } // endregion + + // region getGapRecommendations + + @Test + fun `getGapRecommendations uses Claude when on-device AI is off`() = runTest { + val query = mockk>() + every { queries.getAll() } returns query + every { query.executeAsList() } returns listOf(dbItem) + coEvery { aiClient.getGapRecommendations(any(), any()) } returns listOf(gapRecommendation) + + val result = repository.getGapRecommendations() + + assertEquals(listOf(gapRecommendation), result.getOrThrow()) + coVerify(exactly = 0) { onDeviceAi.getGapRecommendations(any(), any()) } + } + + @Test + fun `getGapRecommendations uses the on-device engine when the preference is on`() = runTest { + onDeviceAiEnabled.value = true + val query = mockk>() + every { queries.getAll() } returns query + every { query.executeAsList() } returns listOf(dbItem) + coEvery { onDeviceAi.getGapRecommendations(any(), any()) } returns listOf(gapRecommendation) + + val result = repository.getGapRecommendations() + + assertEquals(listOf(gapRecommendation), result.getOrThrow()) + coVerify(exactly = 0) { aiClient.getGapRecommendations(any(), any()) } + } + + // endregion + + // region analyzeProspectiveItem + + /** Try-It reasons over the whole wardrobe, so it stays on Claude even when the toggle is on. */ + @Test + fun `analyzeProspectiveItem always uses Claude`() = runTest { + onDeviceAiEnabled.value = true + val query = mockk>() + every { queries.getAll() } returns query + every { query.executeAsList() } returns listOf(dbItem) + val tryItResult = TryItResult( + matchingItems = emptyList(), + combinationsUnlocked = 3, + gapsFilled = emptyList(), + worthAdding = true, + ) + coEvery { aiClient.analyzeProspectiveItem(any(), any(), any()) } returns tryItResult + + val result = repository.analyzeProspectiveItem(byteArrayOf(1, 2)) + + assertEquals(tryItResult, result.getOrThrow()) + coVerify { aiClient.analyzeProspectiveItem(any(), any(), any()) } + } + + // endregion + + private companion object { + val gapRecommendation = GapRecommendation( + itemName = "White crew tee", + category = "BASICS", + pairingCount = 12, + colors = listOf("white"), + seasons = listOf(Season.SUMMER), + mappedCategory = Category.TOP, + ) + } } diff --git a/shared/src/androidMain/kotlin/com/github/worn/data/source/ai/AndroidOnDeviceAiEngine.kt b/shared/src/androidMain/kotlin/com/github/worn/data/source/ai/AndroidOnDeviceAiEngine.kt new file mode 100644 index 0000000..9a58c8d --- /dev/null +++ b/shared/src/androidMain/kotlin/com/github/worn/data/source/ai/AndroidOnDeviceAiEngine.kt @@ -0,0 +1,119 @@ +package com.github.worn.data.source.ai + +import com.github.worn.domain.model.OnDeviceAiAvailability +import com.github.worn.domain.model.OnDeviceAiUnavailableReason +import com.google.mlkit.genai.common.DownloadStatus +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.GenerateContentRequest +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.GenerativeModel +import com.google.mlkit.genai.prompt.ImagePart +import com.google.mlkit.genai.prompt.PromptPrefix +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext + +/** + * Gemini Nano through ML Kit GenAI, brokered by the system AICore service. + * + * The model ships with the OS rather than the app, so availability is a device property that + * ML Kit reports directly — no multi-gigabyte download to manage, unlike a bundled Gemma via + * MediaPipe. That check is what lets Settings disable the toggle on unsupported hardware. + */ +class AndroidOnDeviceAiEngine( + private val dispatcher: CoroutineContext, +) : OnDeviceAiEngine { + + override suspend fun availability(): OnDeviceAiAvailability = withContext(dispatcher) { + runCatching { withClient { it.checkStatus() } } + .fold( + onSuccess = { status -> + when (status) { + // Already downloading: it will be ready, so let the user opt in now. + FeatureStatus.AVAILABLE, FeatureStatus.DOWNLOADING -> + OnDeviceAiAvailability.Available + FeatureStatus.DOWNLOADABLE -> OnDeviceAiAvailability.Downloadable + else -> OnDeviceAiAvailability.Unavailable( + OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE, + ) + } + }, + onFailure = { OnDeviceAiAvailability.Unavailable(it.toReason()) }, + ) + } + + override suspend fun generate( + systemPrompt: String, + userText: String, + imageBytes: ByteArray?, + ): String = withContext(dispatcher) { + withClient { model -> + model.ensureModelDownloaded() + + val configure: GenerateContentRequest.Builder.() -> Unit = { + // The prefix is the system-instruction slot; keeping it separate from the user + // text lets AICore cache it across calls. + promptPrefix = PromptPrefix(systemPrompt) + // Near-greedy decoding: these prompts want one exact JSON shape, not variety. + temperature = TEMPERATURE + candidateCount = 1 + maxOutputTokens = MAX_OUTPUT_TOKENS + } + + val request = if (imageBytes != null) { + generateContentRequest(ImagePart(imageBytes), TextPart(userText), configure) + } else { + generateContentRequest(TextPart(userText), configure) + } + + val response = model.generateContent(request) + response.candidates.firstOrNull()?.text?.takeIf { it.isNotBlank() } + ?: error("On-device AI returned an empty response. Please try again.") + } + } + + private suspend fun GenerativeModel.ensureModelDownloaded() { + when (checkStatus()) { + FeatureStatus.AVAILABLE -> return + FeatureStatus.UNAVAILABLE -> + error("On-device AI isn't available on this device. Turn it off in Settings.") + } + download().collect { status -> + if (status is DownloadStatus.DownloadFailed) { + throw IllegalStateException( + "Could not download the on-device AI model. ${status.e.message.orEmpty()}".trim(), + status.e, + ) + } + } + } + + /** + * A client holds an AICore connection, so it is opened per operation and always closed. + * `try`/`finally` rather than `runCatching` because the close has to happen on both paths. + */ + private suspend fun withClient(block: suspend (GenerativeModel) -> T): T { + val model = Generation.getClient() + return try { + block(model) + } finally { + model.close() + } + } + + private fun Throwable.toReason(): OnDeviceAiUnavailableReason = when { + this !is GenAiException -> OnDeviceAiUnavailableReason.UNKNOWN + errorCode == GenAiException.ErrorCode.AICORE_INCOMPATIBLE -> + OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE + errorCode == GenAiException.ErrorCode.NEEDS_SYSTEM_UPDATE -> + OnDeviceAiUnavailableReason.UNSUPPORTED_OS + else -> OnDeviceAiUnavailableReason.UNKNOWN + } + + private companion object { + const val TEMPERATURE = 0.1f + const val MAX_OUTPUT_TOKENS = 1024 + } +} diff --git a/shared/src/androidMain/kotlin/com/github/worn/di/AndroidModule.kt b/shared/src/androidMain/kotlin/com/github/worn/di/AndroidModule.kt index e5ecb21..e13bdbf 100644 --- a/shared/src/androidMain/kotlin/com/github/worn/di/AndroidModule.kt +++ b/shared/src/androidMain/kotlin/com/github/worn/di/AndroidModule.kt @@ -2,6 +2,8 @@ package com.github.worn.di import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import com.github.worn.data.source.ai.AndroidOnDeviceAiEngine +import com.github.worn.data.source.ai.OnDeviceAiEngine import com.github.worn.data.source.image.BackgroundRemover import com.github.worn.data.source.local.DatabaseDriverFactory import com.github.worn.data.source.local.PhotoFileStorage @@ -20,6 +22,7 @@ val androidModule = module { single { get().create() } single { PhotoFileStorage(get()) } single { BackgroundRemover(get()) } + single { AndroidOnDeviceAiEngine(get()) } single { AndroidSecretStore(get()) } single { RsaEncryptor() } single { HttpClient(OkHttp) } diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/repository/SettingsRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/github/worn/data/repository/SettingsRepositoryImpl.kt index 5c04542..522625d 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/repository/SettingsRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/repository/SettingsRepositoryImpl.kt @@ -2,15 +2,19 @@ package com.github.worn.data.repository import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.github.worn.domain.model.AgeRange import com.github.worn.domain.model.BodyType import com.github.worn.domain.model.Climate import com.github.worn.domain.model.Lifestyle +import com.github.worn.domain.model.OnDeviceAiAvailability import com.github.worn.domain.model.StyleProfile import com.github.worn.domain.model.UserProfile +import com.github.worn.domain.model.isUsable import com.github.worn.domain.repository.SettingsRepository +import com.github.worn.data.source.ai.OnDeviceAiEngine import com.github.worn.data.source.local.PhotoFileStorage import com.github.worn.util.secret.SecretStore import kotlinx.coroutines.flow.Flow @@ -24,6 +28,7 @@ class SettingsRepositoryImpl( private val dataStore: DataStore, private val fileStorage: PhotoFileStorage, private val secretStore: SecretStore, + private val onDeviceAi: OnDeviceAiEngine, private val dispatcher: CoroutineContext, ) : SettingsRepository { @@ -168,7 +173,27 @@ class SettingsRepositoryImpl( } } + override fun isOnDeviceAiEnabled(): Flow = + dataStore.data.map { it[KEY_ON_DEVICE_AI] == true } + + override suspend fun setOnDeviceAiEnabled(enabled: Boolean): Result = runCatching { + withContext(dispatcher) { + dataStore.edit { prefs -> prefs[KEY_ON_DEVICE_AI] = enabled } + } + } + + override suspend fun getOnDeviceAiAvailability(): Result = runCatching { + onDeviceAi.availability() + } + + override suspend fun isAiAvailable(): Result = runCatching { + // Short-circuit on the key: it is the cheaper check and the provider the app falls back to. + hasApiKey().getOrDefault(false) || + (isOnDeviceAiEnabled().first() && onDeviceAi.availability().isUsable) + } + companion object { + private val KEY_ON_DEVICE_AI = booleanPreferencesKey("on_device_ai_enabled") private val KEY_BODY_TYPE = stringPreferencesKey("body_type") private val KEY_STYLE_PROFILE = stringPreferencesKey("style_profile") private val KEY_AGE_RANGE = stringPreferencesKey("age_range") diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/repository/WardrobeRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/github/worn/data/repository/WardrobeRepositoryImpl.kt index 652c166..1af3f9f 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/repository/WardrobeRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/repository/WardrobeRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.github.worn.data.repository +import com.github.worn.data.source.ai.OnDeviceAiSource import com.github.worn.data.source.local.PhotoFileStorage import com.github.worn.data.source.local.db.WardrobeDatabase import com.github.worn.data.source.remote.ClaudeApiClient @@ -31,6 +32,7 @@ class WardrobeRepositoryImpl( private val db: WardrobeDatabase, private val fileStorage: PhotoFileStorage, private val aiClient: ClaudeApiClient, + private val onDeviceAi: OnDeviceAiSource, private val settingsRepository: SettingsRepository, private val dispatcher: CoroutineContext, ) : WardrobeRepository { @@ -118,7 +120,11 @@ class WardrobeRepositoryImpl( withContext(dispatcher) { val item = findById(itemId) ?: error("Item not found: $itemId") val imageBytes = fileStorage.read(item.photoPath) - val analysis = aiClient.analyzeImage(imageBytes) + val analysis = if (useOnDeviceAi()) { + onDeviceAi.analyzeImage(imageBytes) + } else { + aiClient.analyzeImage(imageBytes) + } val updated = item.copy( description = analysis.description, @@ -184,10 +190,16 @@ class WardrobeRepositoryImpl( withContext(dispatcher) { val items = db.clothingItemQueries.getAll().executeAsList().map { it.toDomain() } val userProfile = settingsRepository.getUserProfile().first() - aiClient.getGapRecommendations(items, userProfile) + if (useOnDeviceAi()) { + onDeviceAi.getGapRecommendations(items, userProfile) + } else { + aiClient.getGapRecommendations(items, userProfile) + } } } + // Try-It reasons over the whole wardrobe against a new photo, which a small on-device model + // handles poorly, so it stays on Claude regardless of the preference. override suspend fun analyzeProspectiveItem(imageBytes: ByteArray): Result = runCatching { withContext(dispatcher) { @@ -197,6 +209,15 @@ class WardrobeRepositoryImpl( } } + /** + * Choosing the provider is business logic, so it lives here rather than in a data source. + * Only the preference is consulted: the engine itself raises a descriptive error if the + * device stopped supporting it, and there is deliberately no fallback to Claude — opting in + * means photos never leave the device. + */ + private suspend fun useOnDeviceAi(): Boolean = + settingsRepository.isOnDeviceAiEnabled().first() + private suspend fun findById(id: String): ClothingItem? = withContext(dispatcher) { db.clothingItemQueries.getById(id).executeAsOneOrNull()?.toDomain() } diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiEngine.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiEngine.kt new file mode 100644 index 0000000..8db20ff --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiEngine.kt @@ -0,0 +1,32 @@ +package com.github.worn.data.source.ai + +import com.github.worn.domain.model.OnDeviceAiAvailability + +/** + * The platform's built-in language model: Gemini Nano via ML Kit GenAI on Android, Apple + * Intelligence via `FoundationModels` on iOS. + * + * A plain interface with per-platform implementations bound in Koin (the [SecretStore][ + * com.github.worn.util.secret.SecretStore] idiom) rather than an `expect class` (the + * [BackgroundRemover][com.github.worn.data.source.image.BackgroundRemover] idiom), because + * [OnDeviceAiSource] holds real prompt and parsing logic that has to be testable in `commonTest` + * against a fake — which an `expect class` cannot provide. + * + * Deliberately a thin text-in/text-out primitive mirroring `ClaudeApiClient.sendRequest`: prompts + * ([AiPrompts]) and parsing ([AiResponseParser]) stay shared so both providers behave alike. + */ +interface OnDeviceAiEngine { + + suspend fun availability(): OnDeviceAiAvailability + + /** + * Runs [systemPrompt] plus [userText] and optional JPEG [imageBytes], returning the model's + * raw text. Throws with a user-facing message when the model is unavailable or inference + * fails — there is no cloud fallback, so the error surfaces to the caller as-is. + */ + suspend fun generate( + systemPrompt: String, + userText: String, + imageBytes: ByteArray? = null, + ): String +} diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiSource.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiSource.kt new file mode 100644 index 0000000..f9fe162 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiSource.kt @@ -0,0 +1,39 @@ +package com.github.worn.data.source.ai + +import com.github.worn.data.source.ai.AiPrompts.toPromptContext +import com.github.worn.domain.model.AiAnalysisResult +import com.github.worn.domain.model.ClothingItem +import com.github.worn.domain.model.GapRecommendation +import com.github.worn.domain.model.UserProfile + +/** + * The on-device counterpart to [ClaudeApiClient][com.github.worn.data.source.remote.ClaudeApiClient], + * built on the same prompts and parser so both providers return identical domain models. + * + * Only the two features that a small local model handles well are exposed. Try-It analysis needs + * to reason over the whole wardrobe at once and stays cloud-only, so it is absent here rather than + * present-and-throwing — the type system keeps the repository honest. + */ +class OnDeviceAiSource(private val engine: OnDeviceAiEngine) { + + suspend fun analyzeImage(imageBytes: ByteArray): AiAnalysisResult { + val responseText = engine.generate( + systemPrompt = AiPrompts.ANALYZE_SYSTEM_PROMPT + AiPrompts.STRICT_JSON_SUFFIX, + userText = "Analyze this clothing item image.", + imageBytes = imageBytes, + ) + return AiResponseParser.parseAnalysis(responseText) + } + + suspend fun getGapRecommendations( + items: List, + userProfile: UserProfile? = null, + ): List { + val profileContext = userProfile?.toPromptContext() ?: "" + val responseText = engine.generate( + systemPrompt = AiPrompts.GAPS_SYSTEM_PROMPT + AiPrompts.STRICT_JSON_SUFFIX, + userText = "${profileContext}My wardrobe:\n${AiPrompts.wardrobeSummary(items)}", + ) + return AiResponseParser.parseGaps(responseText) + } +} diff --git a/shared/src/commonMain/kotlin/com/github/worn/di/SharedModule.kt b/shared/src/commonMain/kotlin/com/github/worn/di/SharedModule.kt index 3d4b463..f217f56 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/di/SharedModule.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/di/SharedModule.kt @@ -4,6 +4,7 @@ import com.github.worn.data.repository.OutfitRepositoryImpl import com.github.worn.data.repository.SettingsRepositoryImpl import com.github.worn.data.repository.TryOnRepositoryImpl import com.github.worn.data.repository.WardrobeRepositoryImpl +import com.github.worn.data.source.ai.OnDeviceAiSource import com.github.worn.data.source.local.createDatabase import com.github.worn.data.source.remote.ClaudeApiClient import com.github.worn.data.source.remote.YouCamApiClient @@ -24,11 +25,13 @@ val sharedModule = module { single { createDatabase(get()) } singleOf(::ClaudeApiClient) singleOf(::YouCamApiClient) + singleOf(::OnDeviceAiSource) single { SettingsRepositoryImpl( dataStore = get(), fileStorage = get(), secretStore = get(), + onDeviceAi = get(), dispatcher = get(), ) } @@ -40,6 +43,7 @@ val sharedModule = module { db = get(), fileStorage = get(), aiClient = get(), + onDeviceAi = get(), settingsRepository = get(), dispatcher = get(), ) diff --git a/shared/src/commonMain/kotlin/com/github/worn/domain/model/OnDeviceAiAvailability.kt b/shared/src/commonMain/kotlin/com/github/worn/domain/model/OnDeviceAiAvailability.kt new file mode 100644 index 0000000..3477485 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/worn/domain/model/OnDeviceAiAvailability.kt @@ -0,0 +1,35 @@ +package com.github.worn.domain.model + +/** + * Whether this device can run the on-device AI model. + * + * Drives the Settings toggle: it is only interactive for [Available] and [Downloadable]. + */ +sealed interface OnDeviceAiAvailability { + data object Available : OnDeviceAiAvailability + + /** Supported, but the model is fetched on first use. */ + data object Downloadable : OnDeviceAiAvailability + + data class Unavailable(val reason: OnDeviceAiUnavailableReason) : OnDeviceAiAvailability +} + +/** + * Why on-device AI can't be used. An enum rather than a message so the UI can localize it — + * each platform words these differently ("Apple Intelligence" vs. "Gemini Nano"). + */ +enum class OnDeviceAiUnavailableReason { + /** The hardware can't run the model (no Neural Engine / no AICore support). */ + UNSUPPORTED_DEVICE, + + /** The OS is older than the on-device model API requires. */ + UNSUPPORTED_OS, + + /** Supported, but the user has switched the platform AI feature off in system settings. */ + DISABLED_BY_USER, + + UNKNOWN, +} + +val OnDeviceAiAvailability.isUsable: Boolean + get() = this is OnDeviceAiAvailability.Available || this is OnDeviceAiAvailability.Downloadable diff --git a/shared/src/commonMain/kotlin/com/github/worn/domain/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/com/github/worn/domain/repository/SettingsRepository.kt index 3afdcd0..01d13fb 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/domain/repository/SettingsRepository.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/domain/repository/SettingsRepository.kt @@ -4,6 +4,7 @@ import com.github.worn.domain.model.AgeRange import com.github.worn.domain.model.BodyType import com.github.worn.domain.model.Climate import com.github.worn.domain.model.Lifestyle +import com.github.worn.domain.model.OnDeviceAiAvailability import com.github.worn.domain.model.StyleProfile import com.github.worn.domain.model.UserProfile import kotlinx.coroutines.flow.Flow @@ -30,4 +31,17 @@ interface SettingsRepository { suspend fun hasYouCamCredentials(): Result suspend fun saveYouCamCredentials(clientId: String, clientSecret: String): Result suspend fun clearYouCamCredentials(): Result + + /** Whether the user has opted into running AI on the device instead of calling Claude. */ + fun isOnDeviceAiEnabled(): Flow + suspend fun setOnDeviceAiEnabled(enabled: Boolean): Result + + /** Whether this device can run the on-device model at all. Queries the platform each time. */ + suspend fun getOnDeviceAiAvailability(): Result + + /** + * Whether *any* AI provider can serve a request: a Claude key is configured, or on-device AI + * is both enabled and available. Gates the AI features in Wardrobe and Gaps. + */ + suspend fun isAiAvailable(): Result } diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt index 9548857..8279eb0 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt @@ -22,7 +22,8 @@ sealed interface GapsIntent { data class GapsState( val recommendations: List = emptyList(), val isLoading: Boolean = false, - val hasApiKey: Boolean = false, + /** A Claude key is configured, or on-device AI is enabled and available. */ + val isAiAvailable: Boolean = false, val isAiMode: Boolean = false, val error: String? = null, ) @@ -43,10 +44,10 @@ class GapsViewModel( val effects: Flow = _effects.receiveAsFlow() init { - // Resolve the key before branching, and off the main thread — see SettingsRepository. + // Resolve the provider before branching, and off the main thread — see SettingsRepository. viewModelScope.launch { - val hasKey = settingsRepository.hasApiKey().getOrDefault(false) - _state.update { it.copy(hasApiKey = hasKey, isAiMode = hasKey) } + val hasAi = settingsRepository.isAiAvailable().getOrDefault(false) + _state.update { it.copy(isAiAvailable = hasAi, isAiMode = hasAi) } loadGaps() } } diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/SettingsViewModel.kt index c42460e..84f8ed4 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/SettingsViewModel.kt @@ -6,8 +6,11 @@ import com.github.worn.domain.model.AgeRange import com.github.worn.domain.model.BodyType import com.github.worn.domain.model.Climate import com.github.worn.domain.model.Lifestyle +import com.github.worn.domain.model.OnDeviceAiAvailability +import com.github.worn.domain.model.OnDeviceAiUnavailableReason import com.github.worn.domain.model.StyleProfile import com.github.worn.domain.model.UserProfile +import com.github.worn.domain.model.isUsable import com.github.worn.domain.repository.SettingsRepository import com.github.worn.domain.repository.TryOnRepository import kotlinx.coroutines.channels.Channel @@ -15,6 +18,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -30,6 +34,7 @@ sealed interface SettingsIntent { data object ClearApiKey : SettingsIntent data class SaveYouCamCredentials(val clientId: String, val clientSecret: String) : SettingsIntent data object ClearYouCamCredentials : SettingsIntent + data class SetOnDeviceAi(val enabled: Boolean) : SettingsIntent } data class SettingsState( @@ -39,6 +44,9 @@ data class SettingsState( val hasYouCamKey: Boolean = false, val verifyingYouCam: Boolean = false, val youCamError: String? = null, + val onDeviceAiEnabled: Boolean = false, + val onDeviceAiAvailability: OnDeviceAiAvailability = + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNKNOWN), val error: String? = null, ) @@ -71,7 +79,36 @@ class SettingsViewModel( viewModelScope.launch { val hasApiKey = settingsRepository.hasApiKey().getOrDefault(false) val hasYouCamKey = settingsRepository.hasYouCamCredentials().getOrDefault(false) - _state.update { it.copy(hasApiKey = hasApiKey, hasYouCamKey = hasYouCamKey) } + val availability = settingsRepository.getOnDeviceAiAvailability() + .getOrDefault(OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNKNOWN)) + var onDeviceAiEnabled = settingsRepository.isOnDeviceAiEnabled().first() + + // The device can lose support after opt-in (Apple Intelligence switched off, model + // evicted). Clear the preference so the repository stops routing to a dead engine and + // the toggle reflects reality. + if (onDeviceAiEnabled && !availability.isUsable) { + settingsRepository.setOnDeviceAiEnabled(false) + onDeviceAiEnabled = false + } + + _state.update { + it.copy( + hasApiKey = hasApiKey, + hasYouCamKey = hasYouCamKey, + onDeviceAiEnabled = onDeviceAiEnabled, + onDeviceAiAvailability = availability, + ) + } + } + } + + private fun setOnDeviceAi(enabled: Boolean) { + viewModelScope.launch { + settingsRepository.setOnDeviceAiEnabled(enabled) + .onSuccess { _state.update { it.copy(onDeviceAiEnabled = enabled) } } + .onFailure { error -> + _effects.send(SettingsEffect.ShowError(error.message ?: "Failed to save")) + } } } @@ -87,6 +124,7 @@ class SettingsViewModel( is SettingsIntent.ClearApiKey -> clearApiKey() is SettingsIntent.SaveYouCamCredentials -> saveYouCamCredentials(intent.clientId, intent.clientSecret) is SettingsIntent.ClearYouCamCredentials -> clearYouCamCredentials() + is SettingsIntent.SetOnDeviceAi -> setOnDeviceAi(intent.enabled) } } diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/WardrobeViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/WardrobeViewModel.kt index bf2b21a..02a5d28 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/WardrobeViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/WardrobeViewModel.kt @@ -48,7 +48,8 @@ data class WardrobeState( val isDeleting: Boolean = false, val selectedIds: Set = emptySet(), val activeCategory: Category? = null, - val hasApiKey: Boolean = false, + /** A Claude key is configured, or on-device AI is enabled and available. */ + val isAiAvailable: Boolean = false, val error: String? = null, val totalItemCount: Int = 0, ) @@ -93,13 +94,13 @@ class WardrobeViewModel( }.stateIn(viewModelScope, SharingStarted.Eagerly, WardrobeState(isLoading = true)) init { - refreshApiKeyState() + refreshAiAvailability() } - private fun refreshApiKeyState() { + private fun refreshAiAvailability() { viewModelScope.launch { - val hasApiKey = settingsRepository.hasApiKey().getOrDefault(false) - _uiState.update { it.copy(hasApiKey = hasApiKey) } + val isAiAvailable = settingsRepository.isAiAvailable().getOrDefault(false) + _uiState.update { it.copy(isAiAvailable = isAiAvailable) } } } diff --git a/shared/src/commonTest/kotlin/com/github/worn/ai/OnDeviceAiSourceTest.kt b/shared/src/commonTest/kotlin/com/github/worn/ai/OnDeviceAiSourceTest.kt new file mode 100644 index 0000000..e30acb4 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/ai/OnDeviceAiSourceTest.kt @@ -0,0 +1,186 @@ +package com.github.worn.ai + +import com.github.worn.data.source.ai.OnDeviceAiSource +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.Fit +import com.github.worn.domain.model.Material +import com.github.worn.domain.model.Season +import com.github.worn.domain.model.Subcategory +import com.github.worn.domain.model.UserProfile +import com.github.worn.domain.model.BodyType +import com.github.worn.fake.FakeOnDeviceAiEngine +import com.github.worn.fake.clothingItem +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OnDeviceAiSourceTest { + + private val engine = FakeOnDeviceAiEngine() + private val source = OnDeviceAiSource(engine) + + // region analyzeImage + + @Test + fun `analyzeImage maps a well-formed reply to the domain model`() = runTest { + engine.response = """ + { + "description": "A navy wool overcoat", + "suggested_category": "OUTERWEAR", + "colors": ["navy"], + "seasons": ["FALL", "WINTER"], + "tags": ["formal", "warm"], + "suggested_subcategory": "COAT", + "suggested_fit": "REGULAR", + "suggested_material": "WOOL" + } + """.trimIndent() + + val result = source.analyzeImage(byteArrayOf(1, 2, 3)) + + assertEquals("A navy wool overcoat", result.description) + assertEquals(Category.OUTERWEAR, result.suggestedCategory) + assertEquals(listOf("navy"), result.colors) + assertEquals(listOf(Season.FALL, Season.WINTER), result.seasons) + assertEquals(listOf("formal", "warm"), result.tags) + assertEquals(Subcategory.COAT, result.suggestedSubcategory) + assertEquals(Fit.REGULAR, result.suggestedFit) + assertEquals(Material.WOOL, result.suggestedMaterial) + } + + @Test + fun `analyzeImage passes the image through to the engine`() = runTest { + engine.response = MINIMAL_ANALYSIS + val bytes = byteArrayOf(9, 8, 7) + + source.analyzeImage(bytes) + + assertEquals(bytes, engine.lastImageBytes) + } + + @Test + fun `analyzeImage tells the model to skip markdown`() = runTest { + engine.response = MINIMAL_ANALYSIS + + source.analyzeImage(byteArrayOf(1)) + + assertContains(engine.lastSystemPrompt.orEmpty(), "Output raw JSON only") + } + + /** Small models fence their JSON despite the instruction; the parser has to cope. */ + @Test + fun `analyzeImage unwraps a fenced reply`() = runTest { + engine.response = "```json\n$MINIMAL_ANALYSIS\n```" + + val result = source.analyzeImage(byteArrayOf(1)) + + assertEquals("A plain tee", result.description) + } + + @Test + fun `analyzeImage falls back for unknown enum values rather than failing`() = runTest { + engine.response = """ + { + "description": "Something new", + "suggested_category": "SPACESUIT", + "colors": [], + "seasons": ["MONSOON", "SUMMER"], + "tags": [], + "suggested_fit": "SNUG" + } + """.trimIndent() + + val result = source.analyzeImage(byteArrayOf(1)) + + assertEquals(Category.TOP, result.suggestedCategory) + assertEquals(listOf(Season.SUMMER), result.seasons) + assertNull(result.suggestedFit) + } + + @Test + fun `analyzeImage fails when the reply is not JSON`() = runTest { + engine.response = "Sure! Here is a description of the shirt." + + assertFailsWith { source.analyzeImage(byteArrayOf(1)) } + } + + @Test + fun `analyzeImage surfaces engine failures without retrying`() = runTest { + engine.failure = IllegalStateException("Model unavailable") + + val error = assertFailsWith { source.analyzeImage(byteArrayOf(1)) } + + assertEquals("Model unavailable", error.message) + assertEquals(1, engine.generateCount) + } + + // endregion + + // region getGapRecommendations + + @Test + fun `getGapRecommendations maps a JSON array to recommendations`() = runTest { + engine.response = """ + [{"item_name": "White crew tee", "category": "BASICS", "pairing_count": 12, + "subcategory": "T_SHIRT", "colors": ["white"], "seasons": ["SUMMER"]}] + """.trimIndent() + + val result = source.getGapRecommendations(listOf(clothingItem())) + + assertEquals(1, result.size) + assertEquals("White crew tee", result.first().itemName) + assertEquals(Subcategory.T_SHIRT, result.first().subcategory) + assertEquals(Category.TOP, result.first().mappedCategory) + } + + @Test + fun `getGapRecommendations sends no image and summarizes the wardrobe`() = runTest { + engine.response = "[]" + + source.getGapRecommendations(listOf(clothingItem(name = "Blue T-Shirt"))) + + assertNull(engine.lastImageBytes) + assertContains(engine.lastUserText.orEmpty(), "Blue T-Shirt") + } + + @Test + fun `getGapRecommendations includes the user profile when present`() = runTest { + engine.response = "[]" + + source.getGapRecommendations( + items = listOf(clothingItem()), + userProfile = UserProfile(bodyType = BodyType.ATHLETIC), + ) + + assertContains(engine.lastUserText.orEmpty(), "Body type: athletic") + } + + @Test + fun `getGapRecommendations omits profile context when there is no profile`() = runTest { + engine.response = "[]" + + source.getGapRecommendations(items = listOf(clothingItem()), userProfile = null) + + val userText = assertNotNull(engine.lastUserText) + assertTrue(userText.startsWith("My wardrobe:"), "Unexpected prompt: $userText") + } + + // endregion + + private companion object { + val MINIMAL_ANALYSIS = """ + { + "description": "A plain tee", + "suggested_category": "TOP", + "colors": ["white"], + "seasons": ["SUMMER"], + "tags": [] + } + """.trimIndent() + } +} diff --git a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeOnDeviceAiEngine.kt b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeOnDeviceAiEngine.kt new file mode 100644 index 0000000..0f48dcb --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeOnDeviceAiEngine.kt @@ -0,0 +1,38 @@ +package com.github.worn.fake + +import com.github.worn.data.source.ai.OnDeviceAiEngine +import com.github.worn.domain.model.OnDeviceAiAvailability +import com.github.worn.domain.model.OnDeviceAiUnavailableReason + +class FakeOnDeviceAiEngine : OnDeviceAiEngine { + var availability: OnDeviceAiAvailability = + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE) + + /** Returned verbatim by [generate], so tests can feed fenced or malformed replies. */ + var response: String = "{}" + var failure: Throwable? = null + + var lastSystemPrompt: String? = null + private set + var lastUserText: String? = null + private set + var lastImageBytes: ByteArray? = null + private set + var generateCount: Int = 0 + private set + + override suspend fun availability(): OnDeviceAiAvailability = availability + + override suspend fun generate( + systemPrompt: String, + userText: String, + imageBytes: ByteArray?, + ): String { + generateCount++ + lastSystemPrompt = systemPrompt + lastUserText = userText + lastImageBytes = imageBytes + failure?.let { throw it } + return response + } +} diff --git a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSettingsRepository.kt b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSettingsRepository.kt index 6647d0c..d883e02 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSettingsRepository.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSettingsRepository.kt @@ -4,8 +4,11 @@ import com.github.worn.domain.model.AgeRange import com.github.worn.domain.model.BodyType import com.github.worn.domain.model.Climate import com.github.worn.domain.model.Lifestyle +import com.github.worn.domain.model.OnDeviceAiAvailability +import com.github.worn.domain.model.OnDeviceAiUnavailableReason import com.github.worn.domain.model.StyleProfile import com.github.worn.domain.model.UserProfile +import com.github.worn.domain.model.isUsable import com.github.worn.domain.repository.SettingsRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +19,9 @@ class FakeSettingsRepository : SettingsRepository { var apiKey: String? = null var youCamClientId: String? = null var youCamClientSecret: String? = null + val onDeviceAiEnabled = MutableStateFlow(false) + var onDeviceAiAvailability: OnDeviceAiAvailability = + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE) override fun getUserProfile(): Flow = profile @@ -64,4 +70,18 @@ class FakeSettingsRepository : SettingsRepository { youCamClientSecret = null return Result.success(Unit) } + + override fun isOnDeviceAiEnabled(): Flow = onDeviceAiEnabled + + override suspend fun setOnDeviceAiEnabled(enabled: Boolean): Result { + onDeviceAiEnabled.value = enabled + return Result.success(Unit) + } + + override suspend fun getOnDeviceAiAvailability(): Result = + Result.success(onDeviceAiAvailability) + + override suspend fun isAiAvailable(): Result = Result.success( + apiKey != null || (onDeviceAiEnabled.value && onDeviceAiAvailability.isUsable), + ) } diff --git a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/SettingsViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/SettingsViewModelTest.kt new file mode 100644 index 0000000..91a6749 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/SettingsViewModelTest.kt @@ -0,0 +1,156 @@ +package com.github.worn.viewmodel + +import com.github.worn.domain.model.OnDeviceAiAvailability +import com.github.worn.domain.model.OnDeviceAiUnavailableReason +import com.github.worn.fake.FakeSettingsRepository +import com.github.worn.fake.FakeTryOnRepository +import com.github.worn.presentation.viewmodel.SettingsIntent +import com.github.worn.presentation.viewmodel.SettingsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var settings: FakeSettingsRepository + private lateinit var tryOn: FakeTryOnRepository + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + settings = FakeSettingsRepository() + tryOn = FakeTryOnRepository() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = SettingsViewModel(settings, tryOn) + + // region on-device AI + + @Test + fun `init reports on-device AI as unavailable by default`() { + val vm = createViewModel() + + assertFalse(vm.state.value.onDeviceAiEnabled) + assertEquals( + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE), + vm.state.value.onDeviceAiAvailability, + ) + } + + @Test + fun `init surfaces the availability reported by the repository`() { + settings.onDeviceAiAvailability = OnDeviceAiAvailability.Available + + val vm = createViewModel() + + assertEquals(OnDeviceAiAvailability.Available, vm.state.value.onDeviceAiAvailability) + } + + @Test + fun `init keeps the preference when the device still supports it`() { + settings.onDeviceAiAvailability = OnDeviceAiAvailability.Available + settings.onDeviceAiEnabled.value = true + + val vm = createViewModel() + + assertTrue(vm.state.value.onDeviceAiEnabled) + assertTrue(settings.onDeviceAiEnabled.value) + } + + /** Apple Intelligence can be switched off, or the model evicted, after the user opted in. */ + @Test + fun `init clears the preference when the device lost support`() { + settings.onDeviceAiEnabled.value = true + settings.onDeviceAiAvailability = + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.DISABLED_BY_USER) + + val vm = createViewModel() + + assertFalse(vm.state.value.onDeviceAiEnabled) + assertFalse(settings.onDeviceAiEnabled.value, "The stored preference should be cleared too") + } + + @Test + fun `init treats a downloadable model as still opted in`() { + settings.onDeviceAiEnabled.value = true + settings.onDeviceAiAvailability = OnDeviceAiAvailability.Downloadable + + val vm = createViewModel() + + assertTrue(vm.state.value.onDeviceAiEnabled) + } + + @Test + fun `SetOnDeviceAi persists the preference and updates state`() { + settings.onDeviceAiAvailability = OnDeviceAiAvailability.Available + val vm = createViewModel() + + vm.onIntent(SettingsIntent.SetOnDeviceAi(enabled = true)) + + assertTrue(vm.state.value.onDeviceAiEnabled) + assertTrue(settings.onDeviceAiEnabled.value) + } + + @Test + fun `SetOnDeviceAi turns the preference back off`() { + settings.onDeviceAiAvailability = OnDeviceAiAvailability.Available + settings.onDeviceAiEnabled.value = true + val vm = createViewModel() + + vm.onIntent(SettingsIntent.SetOnDeviceAi(enabled = false)) + + assertFalse(vm.state.value.onDeviceAiEnabled) + assertFalse(settings.onDeviceAiEnabled.value) + } + + // endregion + + // region credentials + + @Test + fun `init reports an existing Claude key`() { + settings.apiKey = "sk-ant-test" + + val vm = createViewModel() + + assertTrue(vm.state.value.hasApiKey) + } + + @Test + fun `SaveApiKey stores the key and flips hasApiKey`() { + val vm = createViewModel() + + vm.onIntent(SettingsIntent.SaveApiKey("sk-ant-test")) + + assertTrue(vm.state.value.hasApiKey) + assertEquals("sk-ant-test", settings.apiKey) + } + + @Test + fun `ClearApiKey removes the key`() { + settings.apiKey = "sk-ant-test" + val vm = createViewModel() + + vm.onIntent(SettingsIntent.ClearApiKey) + + assertFalse(vm.state.value.hasApiKey) + assertEquals(null, settings.apiKey) + } + + // endregion +} diff --git a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt index 88f20c8..865f616 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt @@ -3,6 +3,7 @@ package com.github.worn.viewmodel import app.cash.turbine.test import com.github.worn.domain.model.Category import com.github.worn.domain.model.Season +import com.github.worn.domain.model.OnDeviceAiAvailability import com.github.worn.fake.FakeSettingsRepository import com.github.worn.fake.FakeWardrobeRepository import com.github.worn.fake.clothingItem @@ -49,17 +50,38 @@ class WardrobeViewModelTest { // region init @Test - fun `init sets hasApiKey true when key exists`() { + fun `init sets isAiAvailable true when a Claude key exists`() { settingsRepository.apiKey = "test-key" val vm = createViewModel() - assertTrue(vm.state.value.hasApiKey) + assertTrue(vm.state.value.isAiAvailable) } @Test - fun `init sets hasApiKey false when key is null`() { + fun `init sets isAiAvailable false when there is no provider at all`() { settingsRepository.apiKey = null val vm = createViewModel() - assertFalse(vm.state.value.hasApiKey) + assertFalse(vm.state.value.isAiAvailable) + } + + @Test + fun `init sets isAiAvailable true when on-device AI is enabled without a key`() { + settingsRepository.apiKey = null + settingsRepository.onDeviceAiAvailability = OnDeviceAiAvailability.Available + settingsRepository.onDeviceAiEnabled.value = true + + val vm = createViewModel() + + assertTrue(vm.state.value.isAiAvailable) + } + + @Test + fun `init sets isAiAvailable false when on-device AI is enabled but unsupported`() { + settingsRepository.apiKey = null + settingsRepository.onDeviceAiEnabled.value = true + + val vm = createViewModel() + + assertFalse(vm.state.value.isAiAvailable) } @Test diff --git a/shared/src/iosMain/kotlin/com/github/worn/data/source/ai/IosOnDeviceAiEngine.kt b/shared/src/iosMain/kotlin/com/github/worn/data/source/ai/IosOnDeviceAiEngine.kt new file mode 100644 index 0000000..5267d70 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/github/worn/data/source/ai/IosOnDeviceAiEngine.kt @@ -0,0 +1,62 @@ +package com.github.worn.data.source.ai + +import com.github.worn.domain.model.OnDeviceAiAvailability +import com.github.worn.domain.model.OnDeviceAiUnavailableReason +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.resume + +/** + * Apple Intelligence via `FoundationModels`, reached through the Swift [OnDeviceAiBridge]. + * + * When no bridge is registered the app is running on an OS older than the framework requires + * (the Swift side guards with `#available`), so that reports as [UNSUPPORTED_OS][ + * OnDeviceAiUnavailableReason.UNSUPPORTED_OS] rather than an error. + */ +class IosOnDeviceAiEngine( + private val dispatcher: CoroutineContext, +) : OnDeviceAiEngine { + + override suspend fun availability(): OnDeviceAiAvailability = withContext(dispatcher) { + val bridge = OnDeviceAiBridgeRegistry.bridge + ?: return@withContext OnDeviceAiAvailability.Unavailable( + OnDeviceAiUnavailableReason.UNSUPPORTED_OS, + ) + val token = suspendCancellableCoroutine { continuation -> + bridge.availability { continuation.resume(it) } + } + token.toAvailability() + } + + private fun OnDeviceAiAvailabilityToken.toAvailability(): OnDeviceAiAvailability = when (this) { + OnDeviceAiAvailabilityToken.AVAILABLE -> OnDeviceAiAvailability.Available + OnDeviceAiAvailabilityToken.DOWNLOADABLE -> OnDeviceAiAvailability.Downloadable + OnDeviceAiAvailabilityToken.UNSUPPORTED_DEVICE -> + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE) + OnDeviceAiAvailabilityToken.UNSUPPORTED_OS -> + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNSUPPORTED_OS) + OnDeviceAiAvailabilityToken.DISABLED_BY_USER -> + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.DISABLED_BY_USER) + OnDeviceAiAvailabilityToken.UNKNOWN -> + OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNKNOWN) + } + + override suspend fun generate( + systemPrompt: String, + userText: String, + imageBytes: ByteArray?, + ): String = withContext(dispatcher) { + val bridge = OnDeviceAiBridgeRegistry.bridge + ?: error("On-device AI isn't available on this device. Turn it off in Settings.") + val result = suspendCancellableCoroutine { continuation -> + bridge.generate(systemPrompt, userText, imageBytes) { response, errorMessage -> + continuation.resume(response to errorMessage) + } + } + val (response, errorMessage) = result + if (errorMessage != null) error(errorMessage) + response?.takeIf { it.isNotBlank() } + ?: error("On-device AI returned an empty response. Please try again.") + } +} diff --git a/shared/src/iosMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiBridge.kt b/shared/src/iosMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiBridge.kt new file mode 100644 index 0000000..36bf347 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/github/worn/data/source/ai/OnDeviceAiBridge.kt @@ -0,0 +1,48 @@ +package com.github.worn.data.source.ai + +/** + * The Swift half of the iOS on-device AI engine. + * + * Apple's `FoundationModels` is a **Swift-only** framework: it exposes no Objective-C interface, + * and Kotlin/Native interop reaches C and Objective-C only. So unlike `Vision` in + * [BackgroundRemover.ios.kt][com.github.worn.data.source.image.BackgroundRemover], it cannot be + * called from `iosMain` — the implementation lives in `OnDeviceAiService.swift` and is handed to + * Kotlin at launch through [OnDeviceAiBridgeRegistry]. + * + * Callbacks rather than `suspend` members because Swift types cannot implement Kotlin `suspend` + * functions; [IosOnDeviceAiEngine] converts them back into suspending calls. + */ +interface OnDeviceAiBridge { + + fun availability(onResult: (OnDeviceAiAvailabilityToken) -> Unit) + + /** + * Runs the prompt and calls back with `(responseText, errorMessage)` — exactly one is non-null. + */ + fun generate( + systemPrompt: String, + userText: String, + imageBytes: ByteArray?, + onResult: (String?, String?) -> Unit, + ) +} + +/** + * What Swift reports back, kept separate from [OnDeviceAiAvailability][ + * com.github.worn.domain.model.OnDeviceAiAvailability] because a flat enum bridges to Swift as + * plain cases (`.available`) whereas a sealed interface would make Swift construct Kotlin + * instances. Mapping to the domain model stays in [IosOnDeviceAiEngine]. + */ +enum class OnDeviceAiAvailabilityToken { + AVAILABLE, + DOWNLOADABLE, + UNSUPPORTED_DEVICE, + UNSUPPORTED_OS, + DISABLED_BY_USER, + UNKNOWN, +} + +/** Set once from `iOSApp.init()`, before any screen can resolve the engine from Koin. */ +object OnDeviceAiBridgeRegistry { + var bridge: OnDeviceAiBridge? = null +} diff --git a/shared/src/iosMain/kotlin/com/github/worn/di/IosModule.kt b/shared/src/iosMain/kotlin/com/github/worn/di/IosModule.kt index a08164f..a6b5a47 100644 --- a/shared/src/iosMain/kotlin/com/github/worn/di/IosModule.kt +++ b/shared/src/iosMain/kotlin/com/github/worn/di/IosModule.kt @@ -2,6 +2,8 @@ package com.github.worn.di import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import com.github.worn.data.source.ai.IosOnDeviceAiEngine +import com.github.worn.data.source.ai.OnDeviceAiEngine import com.github.worn.data.source.image.BackgroundRemover import com.github.worn.data.source.local.DatabaseDriverFactory import com.github.worn.data.source.local.PhotoFileStorage @@ -21,6 +23,7 @@ val iosModule = module { single { get().create() } single { PhotoFileStorage() } single { BackgroundRemover(get()) } + single { IosOnDeviceAiEngine(get()) } single { IosSecretStore() } single { RsaEncryptor() } single { HttpClient(Darwin) }