Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ app/src/main/java/com/b2/ultraprocessed/ui/DemoImageSamples.kt
app/src/main/java/com/b2/ultraprocessed/ui/DemoSamplePickerDialog.kt
app/src/main/java/com/b2/ultraprocessed/classify/RulesClassifier.kt
app/src/main/java/com/b2/ultraprocessed/classify/OnDeviceLLMClassifier.kt
app/src/main/java/com/b2/ultraprocessed/storage/datastore/AppSettings.kt
app/src/main/java/com/b2/ultraprocessed/storage/datastore/package-info.md
app/src/test/java/com/b2/ultraprocessed/classify/ClassifierOrchestratorTest.kt
app/src/test/java/com/b2/ultraprocessed/classify/RulesClassifierTest.kt
app/src/test/java/com/b2/ultraprocessed/classify/NovaIngredientSampleFixturesTest.kt
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ In practice, Zest is about:
- spotting ingredient-level signals clearly
- choosing healthier options with more confidence

## What Zest Does

- Scan food ingredient labels with the camera.
- Import a label photo from your gallery.
- Scan a barcode and look up product data when USDA access is configured.
- Show a NOVA-style result for the full label.
- Break ingredients into compact color-coded bubbles.
- Show allergen signals in a separate section.
- Save scan history locally on your phone.
- Show token and cost usage in scan history, using provider-reported usage when available.
- Use the same Zest splash, launcher icon, typography, and sound setting across the app.
- Keep API keys encrypted on device.

## Feature List

Zest is designed as a consumer-grade ingredient intelligence layer: fast enough for the grocery aisle, structured enough for product decisions, and privacy-conscious enough for everyday trust.
Expand Down
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ val retiredSourcePaths = listOf(
"src/main/java/com/b2/ultraprocessed/ui/DemoSamplePickerDialog.kt",
"src/main/java/com/b2/ultraprocessed/classify/RulesClassifier.kt",
"src/main/java/com/b2/ultraprocessed/classify/OnDeviceLLMClassifier.kt",
"src/main/java/com/b2/ultraprocessed/storage/datastore/AppSettings.kt",
"src/main/java/com/b2/ultraprocessed/storage/datastore/package-info.md",
"src/test/java/com/b2/ultraprocessed/classify/ClassifierOrchestratorTest.kt",
"src/test/java/com/b2/ultraprocessed/classify/RulesClassifierTest.kt",
"src/test/java/com/b2/ultraprocessed/classify/NovaIngredientSampleFixturesTest.kt",
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/assets/nova_classification_cards.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"novaClassificationCards": {
"1": {
"headline": "Pure and simple — closest to food in its natural form...."
},
"2": {
"headline": "Kitchen essential — refined from nature, made for cooking."
},
"3": {
"headline": "Processed, not extreme — upgraded with added ingredients."
},
"4": {
"headline": "Engineered food — industrially built, heavily processed."
}
}
}
138 changes: 38 additions & 100 deletions app/src/main/java/com/b2/ultraprocessed/ui/ResultsScreen.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.b2.ultraprocessed.ui

import android.content.res.AssetManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
Expand Down Expand Up @@ -46,6 +47,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
Expand All @@ -59,11 +61,14 @@ import com.b2.ultraprocessed.ui.theme.Amber400
import com.b2.ultraprocessed.ui.theme.Emerald500
import com.b2.ultraprocessed.ui.audio.AppSoundEvent
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject

@Composable
fun ResultsScreen(
Expand Down Expand Up @@ -187,7 +192,9 @@ private fun FullAnalysisResultBody(
onAskAboutResult: suspend (String, (String) -> Unit) -> Result<com.b2.ultraprocessed.network.llm.ResultChatReply>,
) {
val verdict = verdictColors(result.novaGroup)
val headline = novaStaticComment(result.novaGroup, result.analyzedAtMillis)
val context = LocalContext.current
val novaCardCopy = remember(context) { NovaClassificationCardCopy.load(context.assets) }
val headline = novaCardCopy.headlineFor(result.novaGroup)
val ingredientItems = remember(result.ingredientAssessments) {
result.ingredientAssessments
.sortedWith(compareBy<IngredientBubbleUi> { it.novaGroup }.thenBy { it.name.lowercase() })
Expand Down Expand Up @@ -510,108 +517,39 @@ private fun String.toReadableToken(): String {
return String(chars)
}

private fun novaStaticComment(novaGroup: Int, seed: Long): String {
val options = when (novaGroup) {
1 -> NOVA_1_COMMENTS
2 -> NOVA_2_COMMENTS
3 -> NOVA_3_COMMENTS
else -> NOVA_4_COMMENTS
}
val index = (seed.takeIf { it != 0L } ?: novaGroup.toLong())
.mod(options.size)
return options[index]
}

private val NOVA_1_COMMENTS = listOf(
"A clean-label direction with minimal processing signals.",
"Closest to whole-food territory in the NOVA framework.",
"A simpler processing profile than most packaged options.",
"Minimal processing signals make this the lightest NOVA tier.",
"This classification points toward basic, recognizable food handling.",
"A lower-processing result with fewer industrial cues.",
"The ingredient evidence fits a minimally processed pattern.",
"A straightforward profile with limited formulation complexity.",
"This sits near the fresh-and-simple end of the scale.",
"Processing appears restrained by NOVA standards.",
"A short path from source food to label.",
"The label evidence stays close to minimally processed food.",
"A calmer ingredient profile with limited industrial intervention.",
"This result suggests simplicity is doing the heavy lifting.",
"A favorable processing class for shoppers avoiding complexity.",
"Minimal-processing evidence is the main story here.",
"A practical green-light category from a processing lens.",
"This reads more like food than formulation.",
"The NOVA signal is comparatively gentle here.",
"A simpler classification with fewer processing red flags.",
)
private data class NovaClassificationCardCopy(
private val headlines: Map<Int, String>,
) {
fun headlineFor(novaGroup: Int): String =
headlines[novaGroup].orEmpty().ifBlank {
DEFAULT_HEADLINES[novaGroup] ?: DEFAULT_HEADLINES.getValue(4)
}

private val NOVA_2_COMMENTS = listOf(
"A culinary ingredient classification, useful mainly in cooking.",
"This belongs more in the pantry than the snack aisle.",
"A cooking-helper profile rather than a complete food verdict.",
"The NOVA signal points to extracted or prepared culinary basics.",
"Best understood as an ingredient used to make other foods.",
"This is processing for preparation, not necessarily formulation.",
"A kitchen-building-block classification.",
"The evidence fits seasoning, cooking, or preparation use.",
"This category is about culinary function, not meal completeness.",
"A pantry-style NOVA result with a narrow role.",
"Think cooking input, not finished-product assessment.",
"The processing signal is functional and culinary.",
"This sits in the ingredient-support tier of NOVA.",
"A simple culinary ingredient profile.",
"Useful context: this class is usually consumed with other foods.",
"This classification flags a cooking component.",
"A refined or extracted ingredient, not a full food evaluation.",
"The NOVA pattern suggests preparation utility.",
"A category for things that help build meals.",
"Culinary ingredient territory: context matters.",
)
companion object {
private const val ASSET_PATH = "nova_classification_cards.json"

private val NOVA_3_COMMENTS = listOf(
"A processed food profile with recognizable ingredients.",
"Processing is present, but not automatically ultra-processed.",
"This sits in the middle: convenient, but worth comparing.",
"A familiar processed-food classification.",
"Recognizable ingredients with added processing steps.",
"This result suggests preservation or preparation beyond basics.",
"A moderate processing signal in NOVA terms.",
"Not the simplest tier, not the most industrial tier.",
"A reasonable place to compare labels side by side.",
"The evidence points to processed, not necessarily ultra-processed.",
"This category often rewards shorter ingredient lists.",
"A processed result where formulation details still matter.",
"A middle-tier classification with room for judgment.",
"Processing is doing some work here.",
"This lands in a practical watch-and-compare zone.",
"A processed-food signal with recognizable structure.",
"The label suggests added culinary ingredients or preservation.",
"A moderate NOVA class: not a free pass, not a panic button.",
"This is where ingredient-list length starts to matter.",
"A balanced classification that benefits from label comparison.",
)
fun load(assets: AssetManager): NovaClassificationCardCopy =
try {
val json = assets.open(ASSET_PATH).bufferedReader().use { it.readText() }
val cards = JSONObject(json).getJSONObject("novaClassificationCards")
NovaClassificationCardCopy(
headlines = (1..4).associateWith { group ->
cards.getJSONObject(group.toString()).getString("headline")
},
)
} catch (_: IOException) {
NovaClassificationCardCopy(DEFAULT_HEADLINES)
} catch (_: JSONException) {
NovaClassificationCardCopy(DEFAULT_HEADLINES)
}
}
}

private val NOVA_4_COMMENTS = listOf(
"A strong ultra-processing signal worth a closer look.",
"This classification points to industrial formulation territory.",
"A high-processing result for shoppers trying to reduce UPFs.",
"The NOVA signal suggests more formulation than simple cooking.",
"This is the class to compare carefully against simpler options.",
"Ultra-processed territory: convenience may be doing the talking.",
"A heavier processing profile by NOVA standards.",
"This result suggests industrial ingredients or additive systems.",
"A clear prompt to check for shorter-label alternatives.",
"The processing signal is strong enough to slow down and compare.",
"This sits at the most processed end of the NOVA scale.",
"A formulation-heavy classification.",
"This is where the label deserves a second read.",
"A high-UPF signal without needing nutrition-panel gymnastics.",
"The ingredient evidence points beyond ordinary kitchen processing.",
"A strong candidate for swap-or-compare shopping.",
"This classification suggests complexity is carrying the product.",
"Ultra-processed by pattern, not by vibes.",
"A red-tier processing result from the label evidence.",
"This is the NOVA group most shoppers use as a caution flag.",
private val DEFAULT_HEADLINES = mapOf(
1 to "Pure and simple — closest to food in its natural form....",
2 to "Kitchen essential — refined from nature, made for cooking.",
3 to "Processed, not extreme — upgraded with added ingredients.",
4 to "Engineered food — industrially built, heavily processed.",
)

@Composable
Expand Down
Loading