feat: initial Payfrit Food Android app — barcode scanner, health scores, alternatives, favorites, history, account
This commit is contained in:
commit
9496e1aff4
37 changed files with 3231 additions and 0 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
/app/release/
|
||||||
|
/app/debug/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Payfrit Food Android
|
||||||
|
|
||||||
|
Native Android app for the Payfrit Food product intelligence platform.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
Users scan food product barcodes, get health scores (0-100), NOVA processing classification (1-4),
|
||||||
|
nutrition facts, ingredient analysis, and discover healthier alternatives from sponsors.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Language**: Kotlin
|
||||||
|
- **UI**: Jetpack Compose + Material 3
|
||||||
|
- **Architecture**: MVVM (ViewModel + StateFlow)
|
||||||
|
- **Networking**: Retrofit 2 + kotlinx.serialization
|
||||||
|
- **Image Loading**: Coil
|
||||||
|
- **Barcode Scanning**: CameraX + ML Kit
|
||||||
|
- **Auth Storage**: EncryptedSharedPreferences (Jetpack Security)
|
||||||
|
- **Min SDK**: 26 (Android 8.0)
|
||||||
|
- **Target SDK**: 34
|
||||||
|
|
||||||
|
## API Base URLs
|
||||||
|
- **Production**: `https://food.payfrit.com/api`
|
||||||
|
- **Dev**: `https://dev.payfrit.com/api` (set via BuildConfig)
|
||||||
|
|
||||||
|
## Key Endpoints
|
||||||
|
- `GET /scan.php?barcode=X` — product lookup
|
||||||
|
- `GET /search.php?q=X` — product search
|
||||||
|
- `GET /alternatives.php?productID=X` — healthier alternatives
|
||||||
|
- `POST /user/login.php` — email/password login
|
||||||
|
- `POST /user/register.php` — registration
|
||||||
|
- `GET /user/account.php` — profile
|
||||||
|
- `GET /user/scans.php` — scan history
|
||||||
|
- `GET /user/favorites.php` — favorites list
|
||||||
|
- `POST /user/favorites.php` — add favorite
|
||||||
|
- `DELETE /user/favorites.php?productID=X` — remove favorite
|
||||||
|
- `DELETE /user/account.php` — delete account (GDPR)
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
- Bearer token via `Authorization: Bearer <token>` header
|
||||||
|
- Token stored in EncryptedSharedPreferences
|
||||||
|
- Public endpoints (scan, search, alternatives) work without auth
|
||||||
|
- User endpoints (history, favorites, account) require auth
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
com.payfrit.food/
|
||||||
|
├── PayfritFoodApp.kt # Application class
|
||||||
|
├── MainActivity.kt # Single activity
|
||||||
|
├── data/
|
||||||
|
│ ├── model/Models.kt # All data classes
|
||||||
|
│ ├── local/AuthStorage.kt # Encrypted token storage
|
||||||
|
│ ├── remote/FoodApiClient.kt # Retrofit API client
|
||||||
|
│ └── repository/ # ProductRepository, UserRepository
|
||||||
|
├── navigation/FoodNavHost.kt # Bottom tab + detail navigation
|
||||||
|
└── ui/
|
||||||
|
├── theme/ # Material 3 theme
|
||||||
|
├── components/ # ScoreRing, NOVABadge, DietaryPills, ProductCard
|
||||||
|
└── screens/
|
||||||
|
├── scan/ # Camera barcode scanner + manual entry
|
||||||
|
├── product/ # Product detail with scores, nutrition, ingredients
|
||||||
|
├── alternatives/ # Filtered alternatives list with sponsor cards
|
||||||
|
├── favorites/ # Saved products
|
||||||
|
├── history/ # Scan history
|
||||||
|
└── account/ # Login/register/profile/GDPR
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
- Keystore passwords in `keystore.properties` (gitignored)
|
||||||
|
- Debug builds point to dev API
|
||||||
|
- Release builds point to production API
|
||||||
124
app/build.gradle.kts
Normal file
124
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.payfrit.food"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.payfrit.food"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables { useSupportLibrary = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
val props = java.util.Properties()
|
||||||
|
val propsFile = rootProject.file("keystore.properties")
|
||||||
|
if (propsFile.exists()) {
|
||||||
|
props.load(propsFile.inputStream())
|
||||||
|
storeFile = file(props["storeFile"] as String)
|
||||||
|
storePassword = props["storePassword"] as String
|
||||||
|
keyAlias = props["keyAlias"] as String
|
||||||
|
keyPassword = props["keyPassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
buildConfigField("String", "API_BASE_URL", "\"https://dev.payfrit.com/api\"")
|
||||||
|
buildConfigField("Boolean", "DEV_MODE", "true")
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
buildConfigField("String", "API_BASE_URL", "\"https://food.payfrit.com/api\"")
|
||||||
|
buildConfigField("Boolean", "DEV_MODE", "false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.8"
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Core
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
|
|
||||||
|
// Compose BOM
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
|
|
||||||
|
// Networking
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
|
// Serialization
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
|
||||||
|
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||||
|
|
||||||
|
// Image loading
|
||||||
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
|
||||||
|
// CameraX for barcode scanning
|
||||||
|
implementation("androidx.camera:camera-camera2:1.3.1")
|
||||||
|
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
||||||
|
implementation("androidx.camera:camera-view:1.3.1")
|
||||||
|
implementation("com.google.mlkit:barcode-scanning:17.2.0")
|
||||||
|
|
||||||
|
// Encrypted storage
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("io.mockk:mockk:1.13.9")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
}
|
||||||
25
app/proguard-rules.pro
vendored
Normal file
25
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# kotlinx.serialization
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.AnnotationsKt
|
||||||
|
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
|
||||||
|
-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); }
|
||||||
|
-keep,includedescriptorclasses class com.payfrit.food.**$$serializer { *; }
|
||||||
|
-keepclassmembers class com.payfrit.food.** { *** Companion; }
|
||||||
|
-keepclasseswithmembers class com.payfrit.food.** { kotlinx.serialization.KSerializer serializer(...); }
|
||||||
|
|
||||||
|
# Retrofit
|
||||||
|
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||||
|
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||||
|
-keepclassmembers,allowshrinking,allowobfuscation interface * { @retrofit2.http.* <methods>; }
|
||||||
|
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||||
|
-dontwarn javax.annotation.**
|
||||||
|
-dontwarn kotlin.Unit
|
||||||
|
-dontwarn retrofit2.KotlinExtensions
|
||||||
|
-dontwarn retrofit2.KotlinExtensions$*
|
||||||
|
|
||||||
|
# OkHttp
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
|
||||||
|
# ML Kit
|
||||||
|
-keep class com.google.mlkit.** { *; }
|
||||||
33
app/src/main/AndroidManifest.xml
Normal file
33
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".PayfritFoodApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.PayfritFood"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.PayfritFood"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
29
app/src/main/java/com/payfrit/food/MainActivity.kt
Normal file
29
app/src/main/java/com/payfrit/food/MainActivity.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.payfrit.food
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.payfrit.food.navigation.FoodNavHost
|
||||||
|
import com.payfrit.food.ui.theme.PayfritFoodTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
PayfritFoodTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
FoodNavHost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/src/main/java/com/payfrit/food/PayfritFoodApp.kt
Normal file
27
app/src/main/java/com/payfrit/food/PayfritFoodApp.kt
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.payfrit.food
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class PayfritFoodApp : Application() {
|
||||||
|
|
||||||
|
lateinit var apiClient: com.payfrit.food.data.remote.FoodApiClient
|
||||||
|
private set
|
||||||
|
|
||||||
|
lateinit var authStorage: com.payfrit.food.data.local.AuthStorage
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
authStorage = com.payfrit.food.data.local.AuthStorage(this)
|
||||||
|
apiClient = com.payfrit.food.data.remote.FoodApiClient(
|
||||||
|
baseUrl = BuildConfig.API_BASE_URL,
|
||||||
|
tokenProvider = { authStorage.getToken() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
lateinit var instance: PayfritFoodApp
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/src/main/java/com/payfrit/food/data/local/AuthStorage.kt
Normal file
58
app/src/main/java/com/payfrit/food/data/local/AuthStorage.kt
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package com.payfrit.food.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure token + user info storage using EncryptedSharedPreferences.
|
||||||
|
* Never stores credentials in plain SharedPreferences.
|
||||||
|
*/
|
||||||
|
class AuthStorage(context: Context) {
|
||||||
|
|
||||||
|
private val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val prefs = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"payfrit_food_auth",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveToken(token: String) {
|
||||||
|
prefs.edit().putString(KEY_TOKEN, token).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
|
||||||
|
|
||||||
|
fun saveUser(uuid: String, firstName: String, lastName: String, email: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_UUID, uuid)
|
||||||
|
.putString(KEY_FIRST_NAME, firstName)
|
||||||
|
.putString(KEY_LAST_NAME, lastName)
|
||||||
|
.putString(KEY_EMAIL, email)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserUuid(): String? = prefs.getString(KEY_UUID, null)
|
||||||
|
fun getFirstName(): String? = prefs.getString(KEY_FIRST_NAME, null)
|
||||||
|
fun getLastName(): String? = prefs.getString(KEY_LAST_NAME, null)
|
||||||
|
fun getEmail(): String? = prefs.getString(KEY_EMAIL, null)
|
||||||
|
|
||||||
|
val isLoggedIn: Boolean get() = getToken() != null
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_TOKEN = "auth_token"
|
||||||
|
private const val KEY_UUID = "user_uuid"
|
||||||
|
private const val KEY_FIRST_NAME = "first_name"
|
||||||
|
private const val KEY_LAST_NAME = "last_name"
|
||||||
|
private const val KEY_EMAIL = "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
283
app/src/main/java/com/payfrit/food/data/model/Models.kt
Normal file
283
app/src/main/java/com/payfrit/food/data/model/Models.kt
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
package com.payfrit.food.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// ── Product ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Product(
|
||||||
|
@SerialName("ID") val id: Int,
|
||||||
|
@SerialName("Barcode") val barcode: String,
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("Brand") val brand: String,
|
||||||
|
@SerialName("Description") val description: String? = null,
|
||||||
|
@SerialName("CategoryID") val categoryId: Int? = null,
|
||||||
|
@SerialName("CategoryName") val categoryName: String? = null,
|
||||||
|
@SerialName("ImageURL") val imageUrl: String? = null,
|
||||||
|
@SerialName("NovaGroup") val novaGroup: Int = 0,
|
||||||
|
@SerialName("Ingredients") val ingredients: String? = null,
|
||||||
|
@SerialName("Dietary") val dietary: DietaryInfo? = null,
|
||||||
|
@SerialName("Nutrition") val nutrition: NutritionInfo? = null,
|
||||||
|
@SerialName("Rating") val rating: Rating? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DietaryInfo(
|
||||||
|
@SerialName("IsOrganic") val isOrganic: Boolean = false,
|
||||||
|
@SerialName("IsVegan") val isVegan: Boolean = false,
|
||||||
|
@SerialName("IsVegetarian") val isVegetarian: Boolean = false,
|
||||||
|
@SerialName("IsGlutenFree") val isGlutenFree: Boolean = false,
|
||||||
|
@SerialName("IsDairyFree") val isDairyFree: Boolean = false,
|
||||||
|
@SerialName("IsNutFree") val isNutFree: Boolean = false,
|
||||||
|
) {
|
||||||
|
fun toTagList(): List<String> = buildList {
|
||||||
|
if (isOrganic) add("Organic")
|
||||||
|
if (isVegan) add("Vegan")
|
||||||
|
if (isVegetarian) add("Vegetarian")
|
||||||
|
if (isGlutenFree) add("Gluten-Free")
|
||||||
|
if (isDairyFree) add("Dairy-Free")
|
||||||
|
if (isNutFree) add("Nut-Free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NutritionInfo(
|
||||||
|
@SerialName("Calories") val calories: Double = 0.0,
|
||||||
|
@SerialName("Fat") val fat: Double = 0.0,
|
||||||
|
@SerialName("SaturatedFat") val saturatedFat: Double = 0.0,
|
||||||
|
@SerialName("TransFat") val transFat: Double = 0.0,
|
||||||
|
@SerialName("Carbohydrates") val carbohydrates: Double = 0.0,
|
||||||
|
@SerialName("Sugars") val sugars: Double = 0.0,
|
||||||
|
@SerialName("Fiber") val fiber: Double = 0.0,
|
||||||
|
@SerialName("Protein") val protein: Double = 0.0,
|
||||||
|
@SerialName("Sodium") val sodium: Double = 0.0,
|
||||||
|
@SerialName("ServingSize") val servingSize: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Rating(
|
||||||
|
@SerialName("NutritionScore") val nutritionScore: Int = 0,
|
||||||
|
@SerialName("IngredientScore") val ingredientScore: Int = 0,
|
||||||
|
@SerialName("OverallScore") val overallScore: Int = 0,
|
||||||
|
@SerialName("Grade") val grade: String = "unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Alternative ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AlternativesResponse(
|
||||||
|
@SerialName("ScannedProduct") val scannedProduct: ScannedProductSummary,
|
||||||
|
@SerialName("Alternatives") val alternatives: List<Alternative>,
|
||||||
|
@SerialName("Count") val count: Int,
|
||||||
|
@SerialName("IsPremiumUser") val isPremiumUser: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ScannedProductSummary(
|
||||||
|
@SerialName("ID") val id: Int,
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("OverallScore") val overallScore: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Alternative(
|
||||||
|
@SerialName("ID") val id: Int,
|
||||||
|
@SerialName("Barcode") val barcode: String,
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("Brand") val brand: String,
|
||||||
|
@SerialName("ImageURL") val imageUrl: String? = null,
|
||||||
|
@SerialName("NovaGroup") val novaGroup: Int = 0,
|
||||||
|
@SerialName("Rating") val rating: Rating? = null,
|
||||||
|
@SerialName("Dietary") val dietary: DietaryInfo? = null,
|
||||||
|
@SerialName("IsSponsored") val isSponsored: Boolean = false,
|
||||||
|
@SerialName("Sponsor") val sponsor: SponsorInfo? = null,
|
||||||
|
@SerialName("Delivery") val delivery: DeliveryInfo? = null,
|
||||||
|
@SerialName("Pickup") val pickup: PickupInfo? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SponsorInfo(
|
||||||
|
@SerialName("ID") val id: Int,
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("Type") val type: String? = null,
|
||||||
|
@SerialName("Price") val price: Double? = null,
|
||||||
|
@SerialName("SalePrice") val salePrice: Double? = null,
|
||||||
|
@SerialName("InStock") val inStock: Boolean = true,
|
||||||
|
@SerialName("PurchaseURL") val purchaseUrl: String? = null,
|
||||||
|
@SerialName("StoreLocation") val storeLocation: String? = null,
|
||||||
|
@SerialName("DistanceMiles") val distanceMiles: Double? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeliveryInfo(
|
||||||
|
@SerialName("Available") val available: Boolean = false,
|
||||||
|
@SerialName("Fee") val fee: Double? = null,
|
||||||
|
@SerialName("FreeDelivery") val freeDelivery: Boolean = false,
|
||||||
|
@SerialName("Estimate") val estimate: String? = null,
|
||||||
|
@SerialName("OrderURL") val orderUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PickupInfo(
|
||||||
|
@SerialName("Available") val available: Boolean = false,
|
||||||
|
@SerialName("Estimate") val estimate: String? = null,
|
||||||
|
@SerialName("DistanceMiles") val distanceMiles: Double? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Search ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchResponse(
|
||||||
|
@SerialName("Query") val query: String,
|
||||||
|
@SerialName("Results") val results: List<ProductSummary>,
|
||||||
|
@SerialName("Count") val count: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProductSummary(
|
||||||
|
@SerialName("ID") val id: Int,
|
||||||
|
@SerialName("Barcode") val barcode: String,
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("Brand") val brand: String,
|
||||||
|
@SerialName("ImageURL") val imageUrl: String? = null,
|
||||||
|
@SerialName("OverallScore") val overallScore: Int = 0,
|
||||||
|
@SerialName("Grade") val grade: String = "unknown",
|
||||||
|
@SerialName("NovaGroup") val novaGroup: Int = 0,
|
||||||
|
@SerialName("CategoryName") val categoryName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── User / Auth ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthResponse(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String? = null,
|
||||||
|
val data: AuthData? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthData(
|
||||||
|
@SerialName("UUID") val uuid: String,
|
||||||
|
@SerialName("Token") val token: String,
|
||||||
|
@SerialName("FirstName") val firstName: String? = null,
|
||||||
|
@SerialName("LastName") val lastName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserProfile(
|
||||||
|
@SerialName("UUID") val uuid: String,
|
||||||
|
@SerialName("FirstName") val firstName: String,
|
||||||
|
@SerialName("LastName") val lastName: String,
|
||||||
|
@SerialName("EmailAddress") val email: String,
|
||||||
|
@SerialName("ZIPCode") val zipCode: String? = null,
|
||||||
|
@SerialName("IsPremium") val isPremium: Boolean = false,
|
||||||
|
@SerialName("ScanCount") val scanCount: Int = 0,
|
||||||
|
@SerialName("FavoriteCount") val favoriteCount: Int = 0,
|
||||||
|
@SerialName("MemberSince") val memberSince: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequest(
|
||||||
|
@SerialName("EmailAddress") val email: String,
|
||||||
|
@SerialName("Password") val password: String,
|
||||||
|
@SerialName("FirstName") val firstName: String,
|
||||||
|
@SerialName("LastName") val lastName: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Scan History ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ScanHistoryResponse(
|
||||||
|
@SerialName("Scans") val scans: List<ScanHistoryItem>,
|
||||||
|
@SerialName("Count") val count: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ScanHistoryItem(
|
||||||
|
@SerialName("ProductID") val productId: Int,
|
||||||
|
@SerialName("Barcode") val barcode: String,
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("Brand") val brand: String,
|
||||||
|
@SerialName("ImageURL") val imageUrl: String? = null,
|
||||||
|
@SerialName("OverallScore") val overallScore: Int = 0,
|
||||||
|
@SerialName("Grade") val grade: String = "unknown",
|
||||||
|
@SerialName("NovaGroup") val novaGroup: Int = 0,
|
||||||
|
@SerialName("CategoryName") val categoryName: String? = null,
|
||||||
|
@SerialName("ScannedOn") val scannedOn: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Favorites ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class FavoriteItem(
|
||||||
|
@SerialName("ProductID") val productId: Int,
|
||||||
|
@SerialName("Barcode") val barcode: String,
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("Brand") val brand: String,
|
||||||
|
@SerialName("ImageURL") val imageUrl: String? = null,
|
||||||
|
@SerialName("OverallScore") val overallScore: Int = 0,
|
||||||
|
@SerialName("Grade") val grade: String = "unknown",
|
||||||
|
@SerialName("NovaGroup") val novaGroup: Int = 0,
|
||||||
|
@SerialName("SavedOn") val savedOn: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SimpleResponse(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Filter Options ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class AlternativeFilters(
|
||||||
|
val sort: SortOption = SortOption.RATING,
|
||||||
|
val order: SortOrder = SortOrder.DESC,
|
||||||
|
val novaLevels: Set<Int> = emptySet(),
|
||||||
|
val vegan: Boolean = false,
|
||||||
|
val vegetarian: Boolean = false,
|
||||||
|
val glutenFree: Boolean = false,
|
||||||
|
val dairyFree: Boolean = false,
|
||||||
|
val nutFree: Boolean = false,
|
||||||
|
val organic: Boolean = false,
|
||||||
|
val minPrice: Double? = null,
|
||||||
|
val maxPrice: Double? = null,
|
||||||
|
val delivery: Boolean = false,
|
||||||
|
val pickup: Boolean = false,
|
||||||
|
val sponsoredOnly: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class SortOption(val apiValue: String) {
|
||||||
|
RATING("rating"),
|
||||||
|
PRICE("price"),
|
||||||
|
NOVA("nova"),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SortOrder(val apiValue: String) {
|
||||||
|
ASC("asc"),
|
||||||
|
DESC("desc"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grade helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun gradeColor(grade: String): Long = when (grade.lowercase()) {
|
||||||
|
"excellent" -> 0xFF2E7D32 // dark green
|
||||||
|
"good" -> 0xFF4CAF50 // green
|
||||||
|
"fair" -> 0xFFFF9800 // orange
|
||||||
|
"poor" -> 0xFFF44336 // red
|
||||||
|
else -> 0xFF9E9E9E // gray
|
||||||
|
}
|
||||||
|
|
||||||
|
fun novaColor(nova: Int): Long = when (nova) {
|
||||||
|
1 -> 0xFF4CAF50 // green
|
||||||
|
2 -> 0xFFFFC107 // yellow
|
||||||
|
3 -> 0xFFFF9800 // orange
|
||||||
|
4 -> 0xFFF44336 // red
|
||||||
|
else -> 0xFF9E9E9E
|
||||||
|
}
|
||||||
122
app/src/main/java/com/payfrit/food/data/remote/FoodApiClient.kt
Normal file
122
app/src/main/java/com/payfrit/food/data/remote/FoodApiClient.kt
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package com.payfrit.food.data.remote
|
||||||
|
|
||||||
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import com.payfrit.food.data.model.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.http.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
// ── Retrofit service interface ───────────────────────────────────────
|
||||||
|
|
||||||
|
interface FoodApiService {
|
||||||
|
|
||||||
|
// Product lookup
|
||||||
|
@GET("scan.php")
|
||||||
|
suspend fun lookupProduct(@Query("barcode") barcode: String): Product
|
||||||
|
|
||||||
|
// Search
|
||||||
|
@GET("search.php")
|
||||||
|
suspend fun searchProducts(
|
||||||
|
@Query("q") query: String,
|
||||||
|
@Query("limit") limit: Int = 20,
|
||||||
|
): SearchResponse
|
||||||
|
|
||||||
|
// Alternatives
|
||||||
|
@GET("alternatives.php")
|
||||||
|
suspend fun getAlternatives(
|
||||||
|
@Query("productID") productId: Int,
|
||||||
|
@Query("sort") sort: String? = null,
|
||||||
|
@Query("order") order: String? = null,
|
||||||
|
@Query("lat") lat: Double? = null,
|
||||||
|
@Query("lng") lng: Double? = null,
|
||||||
|
@Query("vegan") vegan: Int? = null,
|
||||||
|
@Query("vegetarian") vegetarian: Int? = null,
|
||||||
|
@Query("glutenFree") glutenFree: Int? = null,
|
||||||
|
@Query("dairyFree") dairyFree: Int? = null,
|
||||||
|
@Query("nutFree") nutFree: Int? = null,
|
||||||
|
@Query("organic") organic: Int? = null,
|
||||||
|
@Query("nova") nova: String? = null,
|
||||||
|
@Query("minPrice") minPrice: Double? = null,
|
||||||
|
@Query("maxPrice") maxPrice: Double? = null,
|
||||||
|
@Query("delivery") delivery: Int? = null,
|
||||||
|
@Query("pickup") pickup: Int? = null,
|
||||||
|
@Query("sponsored") sponsored: Int? = null,
|
||||||
|
): AlternativesResponse
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
@POST("user/login.php")
|
||||||
|
suspend fun login(@Body request: LoginRequest): AuthResponse
|
||||||
|
|
||||||
|
@POST("user/register.php")
|
||||||
|
suspend fun register(@Body request: RegisterRequest): AuthResponse
|
||||||
|
|
||||||
|
// Account
|
||||||
|
@GET("user/account.php")
|
||||||
|
suspend fun getAccount(): UserProfile
|
||||||
|
|
||||||
|
@DELETE("user/account.php")
|
||||||
|
suspend fun deleteAccount(): SimpleResponse
|
||||||
|
|
||||||
|
@POST("user/account.php?export=1")
|
||||||
|
suspend fun exportData(): kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
// Scan history
|
||||||
|
@GET("user/scans.php")
|
||||||
|
suspend fun getScanHistory(@Query("limit") limit: Int = 50): ScanHistoryResponse
|
||||||
|
|
||||||
|
// Favorites
|
||||||
|
@GET("user/favorites.php")
|
||||||
|
suspend fun getFavorites(): List<FavoriteItem>
|
||||||
|
|
||||||
|
@POST("user/favorites.php")
|
||||||
|
suspend fun addFavorite(@Body body: Map<String, Int>): SimpleResponse
|
||||||
|
|
||||||
|
@DELETE("user/favorites.php")
|
||||||
|
suspend fun removeFavorite(@Query("productID") productId: Int): SimpleResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client wrapper ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FoodApiClient(
|
||||||
|
baseUrl: String,
|
||||||
|
private val tokenProvider: () -> String?,
|
||||||
|
) {
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val authInterceptor = Interceptor { chain ->
|
||||||
|
val requestBuilder = chain.request().newBuilder()
|
||||||
|
tokenProvider()?.let { token ->
|
||||||
|
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||||
|
}
|
||||||
|
chain.proceed(requestBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(
|
||||||
|
HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/")
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val api: FoodApiService = retrofit.create(FoodApiService::class.java)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package com.payfrit.food.data.repository
|
||||||
|
|
||||||
|
import com.payfrit.food.data.model.*
|
||||||
|
import com.payfrit.food.data.remote.FoodApiService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class ProductRepository(private val api: FoodApiService) {
|
||||||
|
|
||||||
|
suspend fun lookupByBarcode(barcode: String): Result<Product> = safeCall {
|
||||||
|
api.lookupProduct(barcode)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun search(query: String, limit: Int = 20): Result<SearchResponse> = safeCall {
|
||||||
|
api.searchProducts(query, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAlternatives(
|
||||||
|
productId: Int,
|
||||||
|
filters: AlternativeFilters = AlternativeFilters(),
|
||||||
|
lat: Double? = null,
|
||||||
|
lng: Double? = null,
|
||||||
|
): Result<AlternativesResponse> = safeCall {
|
||||||
|
api.getAlternatives(
|
||||||
|
productId = productId,
|
||||||
|
sort = filters.sort.apiValue,
|
||||||
|
order = filters.order.apiValue,
|
||||||
|
lat = lat,
|
||||||
|
lng = lng,
|
||||||
|
vegan = filters.vegan.toQueryInt(),
|
||||||
|
vegetarian = filters.vegetarian.toQueryInt(),
|
||||||
|
glutenFree = filters.glutenFree.toQueryInt(),
|
||||||
|
dairyFree = filters.dairyFree.toQueryInt(),
|
||||||
|
nutFree = filters.nutFree.toQueryInt(),
|
||||||
|
organic = filters.organic.toQueryInt(),
|
||||||
|
nova = filters.novaLevels.takeIf { it.isNotEmpty() }
|
||||||
|
?.joinToString(","),
|
||||||
|
minPrice = filters.minPrice,
|
||||||
|
maxPrice = filters.maxPrice,
|
||||||
|
delivery = filters.delivery.toQueryInt(),
|
||||||
|
pickup = filters.pickup.toQueryInt(),
|
||||||
|
sponsored = filters.sponsoredOnly.toQueryInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Boolean.toQueryInt(): Int? = if (this) 1 else null
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRepository(private val api: FoodApiService) {
|
||||||
|
|
||||||
|
suspend fun login(email: String, password: String): Result<AuthResponse> = safeCall {
|
||||||
|
api.login(LoginRequest(username = email, password = password))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun register(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
): Result<AuthResponse> = safeCall {
|
||||||
|
api.register(RegisterRequest(email, password, firstName, lastName))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getProfile(): Result<UserProfile> = safeCall {
|
||||||
|
api.getAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteAccount(): Result<SimpleResponse> = safeCall {
|
||||||
|
api.deleteAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getScanHistory(limit: Int = 50): Result<ScanHistoryResponse> = safeCall {
|
||||||
|
api.getScanHistory(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getFavorites(): Result<List<FavoriteItem>> = safeCall {
|
||||||
|
api.getFavorites()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addFavorite(productId: Int): Result<SimpleResponse> = safeCall {
|
||||||
|
api.addFavorite(mapOf("ProductID" to productId))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeFavorite(productId: Int): Result<SimpleResponse> = safeCall {
|
||||||
|
api.removeFavorite(productId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared safe-call helper
|
||||||
|
internal suspend fun <T> safeCall(block: suspend () -> T): Result<T> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Result.success(block())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/src/main/java/com/payfrit/food/navigation/FoodNavHost.kt
Normal file
129
app/src/main/java/com/payfrit/food/navigation/FoodNavHost.kt
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package com.payfrit.food.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.payfrit.food.ui.screens.account.AccountScreen
|
||||||
|
import com.payfrit.food.ui.screens.alternatives.AlternativesScreen
|
||||||
|
import com.payfrit.food.ui.screens.favorites.FavoritesScreen
|
||||||
|
import com.payfrit.food.ui.screens.history.HistoryScreen
|
||||||
|
import com.payfrit.food.ui.screens.product.ProductScreen
|
||||||
|
import com.payfrit.food.ui.screens.scan.ScanScreen
|
||||||
|
|
||||||
|
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
|
||||||
|
data object Scan : Screen("scan", "Scan", Icons.Default.QrCodeScanner)
|
||||||
|
data object Favorites : Screen("favorites", "Favorites", Icons.Default.Favorite)
|
||||||
|
data object History : Screen("history", "History", Icons.Default.History)
|
||||||
|
data object Account : Screen("account", "Account", Icons.Default.Person)
|
||||||
|
}
|
||||||
|
|
||||||
|
val bottomTabs = listOf(Screen.Scan, Screen.Favorites, Screen.History, Screen.Account)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FoodNavHost() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
|
||||||
|
// Hide bottom bar on detail screens
|
||||||
|
val showBottomBar = bottomTabs.any { tab ->
|
||||||
|
currentDestination?.hierarchy?.any { it.route == tab.route } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomBar) {
|
||||||
|
NavigationBar {
|
||||||
|
bottomTabs.forEach { screen ->
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(screen.icon, contentDescription = screen.title) },
|
||||||
|
label = { Text(screen.title) },
|
||||||
|
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(screen.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Screen.Scan.route,
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
composable(Screen.Scan.route) {
|
||||||
|
ScanScreen(
|
||||||
|
onProductScanned = { barcode ->
|
||||||
|
navController.navigate("product/$barcode")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "product/{barcode}",
|
||||||
|
arguments = listOf(navArgument("barcode") { type = NavType.StringType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val barcode = backStackEntry.arguments?.getString("barcode") ?: return@composable
|
||||||
|
ProductScreen(
|
||||||
|
barcode = barcode,
|
||||||
|
onViewAlternatives = { productId ->
|
||||||
|
navController.navigate("alternatives/$productId")
|
||||||
|
},
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "alternatives/{productId}",
|
||||||
|
arguments = listOf(navArgument("productId") { type = NavType.IntType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val productId = backStackEntry.arguments?.getInt("productId") ?: return@composable
|
||||||
|
AlternativesScreen(
|
||||||
|
productId = productId,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Favorites.route) {
|
||||||
|
FavoritesScreen(
|
||||||
|
onProductClick = { barcode ->
|
||||||
|
navController.navigate("product/$barcode")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.History.route) {
|
||||||
|
HistoryScreen(
|
||||||
|
onProductClick = { barcode ->
|
||||||
|
navController.navigate("product/$barcode")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Account.route) {
|
||||||
|
AccountScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.payfrit.food.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DietaryPills(
|
||||||
|
tags: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
"Dietary Info",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
tags.forEach { tag ->
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {},
|
||||||
|
label = { Text(tag, style = MaterialTheme.typography.labelSmall) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.payfrit.food.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.payfrit.food.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NOVABadge(
|
||||||
|
novaGroup: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val color = when (novaGroup) {
|
||||||
|
1 -> Nova1
|
||||||
|
2 -> Nova2
|
||||||
|
3 -> Nova3
|
||||||
|
4 -> Nova4
|
||||||
|
else -> Color.Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
val label = when (novaGroup) {
|
||||||
|
1 -> "Unprocessed"
|
||||||
|
2 -> "Processed ingredients"
|
||||||
|
3 -> "Processed"
|
||||||
|
4 -> "Ultra-processed"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(color),
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "NOVA",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$novaGroup",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/src/main/java/com/payfrit/food/ui/components/ProductCard.kt
Normal file
112
app/src/main/java/com/payfrit/food/ui/components/ProductCard.kt
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package com.payfrit.food.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.payfrit.food.data.model.gradeColor
|
||||||
|
import com.payfrit.food.data.model.novaColor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProductCard(
|
||||||
|
name: String,
|
||||||
|
brand: String,
|
||||||
|
score: Int,
|
||||||
|
grade: String,
|
||||||
|
novaGroup: Int,
|
||||||
|
imageUrl: String?,
|
||||||
|
subtitle: String? = null,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Image
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = name,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Name + brand
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
brand,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
subtitle?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
// Score + NOVA
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
"$score",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(gradeColor(grade)),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
grade.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color(gradeColor(grade)),
|
||||||
|
)
|
||||||
|
if (novaGroup > 0) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
color = Color(novaColor(novaGroup)),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"N$novaGroup",
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/src/main/java/com/payfrit/food/ui/components/ScoreRing.kt
Normal file
106
app/src/main/java/com/payfrit/food/ui/components/ScoreRing.kt
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package com.payfrit.food.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.payfrit.food.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScoreRing(
|
||||||
|
score: Int,
|
||||||
|
grade: String,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Int = 100,
|
||||||
|
) {
|
||||||
|
var animationPlayed by remember { mutableStateOf(false) }
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = if (animationPlayed) score / 100f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 1000),
|
||||||
|
label = "score_animation",
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(score) { animationPlayed = true }
|
||||||
|
|
||||||
|
val ringColor = when (grade.lowercase()) {
|
||||||
|
"excellent" -> GradeExcellent
|
||||||
|
"good" -> GradeGood
|
||||||
|
"fair" -> GradeFair
|
||||||
|
"poor" -> GradePoor
|
||||||
|
else -> Color.Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(size.dp),
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.size(size.dp)) {
|
||||||
|
val strokeWidth = 10f
|
||||||
|
val arcSize = Size(this.size.width - strokeWidth, this.size.height - strokeWidth)
|
||||||
|
val topLeft = Offset(strokeWidth / 2, strokeWidth / 2)
|
||||||
|
|
||||||
|
// Background ring
|
||||||
|
drawArc(
|
||||||
|
color = Color.LightGray.copy(alpha = 0.3f),
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = 360f,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = arcSize,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Score arc
|
||||||
|
drawArc(
|
||||||
|
color = ringColor,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = 360f * animatedProgress,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = arcSize,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$score",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = ringColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = grade.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = ringColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
package com.payfrit.food.ui.screens.account
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AccountScreen(
|
||||||
|
viewModel: AccountViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text("Account") })
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
if (state.isLoggedIn) {
|
||||||
|
LoggedInContent(
|
||||||
|
state = state,
|
||||||
|
onLogout = { viewModel.logout() },
|
||||||
|
onDeleteAccount = { viewModel.deleteAccount() },
|
||||||
|
onExportData = { viewModel.exportData() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AuthContent(
|
||||||
|
state = state,
|
||||||
|
onLogin = { email, password -> viewModel.login(email, password) },
|
||||||
|
onRegister = { email, password, first, last ->
|
||||||
|
viewModel.register(email, password, first, last)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoggedInContent(
|
||||||
|
state: AccountUiState,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onDeleteAccount: () -> Unit,
|
||||||
|
onExportData: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Profile card
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AccountCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"${state.profile?.firstName ?: ""} ${state.profile?.lastName ?: ""}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
state.profile?.email ?: "",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Divider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
state.profile?.let { profile ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
StatItem("Scans", "${profile.scanCount}")
|
||||||
|
StatItem("Favorites", "${profile.favoriteCount}")
|
||||||
|
if (profile.isPremium) {
|
||||||
|
StatItem("Plan", "Premium")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onExportData,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Export My Data")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onLogout,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Logout, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Log Out")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showDeleteDialog = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.DeleteForever, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Delete Account")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
|
title = { Text("Delete Account?") },
|
||||||
|
text = { Text("This will permanently delete all your data. This action cannot be undone.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
onDeleteAccount()
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthContent(
|
||||||
|
state: AccountUiState,
|
||||||
|
onLogin: (String, String) -> Unit,
|
||||||
|
onRegister: (String, String, String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
var isRegistering by remember { mutableStateOf(false) }
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var firstName by remember { mutableStateOf("") }
|
||||||
|
var lastName by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AccountCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
if (isRegistering) "Create Account" else "Log In",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
if (isRegistering) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = firstName,
|
||||||
|
onValueChange = { firstName = it },
|
||||||
|
label = { Text("First Name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lastName,
|
||||||
|
onValueChange = { lastName = it },
|
||||||
|
label = { Text("Last Name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text("Email") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Password") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
state.error?.let { error ->
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (isRegistering) {
|
||||||
|
onRegister(email, password, firstName, lastName)
|
||||||
|
} else {
|
||||||
|
onLogin(email, password)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !state.isLoading && email.isNotBlank() && password.isNotBlank(),
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(if (isRegistering) "Create Account" else "Log In")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = { isRegistering = !isRegistering }) {
|
||||||
|
Text(if (isRegistering) "Already have an account? Log in" else "Don't have an account? Sign up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatItem(label: String, value: String) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
package com.payfrit.food.ui.screens.account
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.payfrit.food.PayfritFoodApp
|
||||||
|
import com.payfrit.food.data.model.UserProfile
|
||||||
|
import com.payfrit.food.data.repository.UserRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class AccountUiState(
|
||||||
|
val isLoggedIn: Boolean = false,
|
||||||
|
val profile: UserProfile? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AccountViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val authStorage = PayfritFoodApp.instance.authStorage
|
||||||
|
private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(
|
||||||
|
AccountUiState(isLoggedIn = authStorage.isLoggedIn)
|
||||||
|
)
|
||||||
|
val state: StateFlow<AccountUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (authStorage.isLoggedIn) loadProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(email: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
userRepo.login(email, password).fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
if (response.success && response.data != null) {
|
||||||
|
authStorage.saveToken(response.data.token)
|
||||||
|
authStorage.saveUser(
|
||||||
|
uuid = response.data.uuid,
|
||||||
|
firstName = response.data.firstName ?: "",
|
||||||
|
lastName = response.data.lastName ?: "",
|
||||||
|
email = email,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(isLoggedIn = true, isLoading = false) }
|
||||||
|
loadProfile()
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = response.message ?: "Login failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(isLoading = false, error = e.message ?: "Network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(email: String, password: String, firstName: String, lastName: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
userRepo.register(email, password, firstName, lastName).fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
if (response.success && response.data != null) {
|
||||||
|
authStorage.saveToken(response.data.token)
|
||||||
|
authStorage.saveUser(
|
||||||
|
uuid = response.data.uuid,
|
||||||
|
firstName = firstName,
|
||||||
|
lastName = lastName,
|
||||||
|
email = email,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(isLoggedIn = true, isLoading = false) }
|
||||||
|
loadProfile()
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = response.message ?: "Registration failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(isLoading = false, error = e.message ?: "Network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
authStorage.clear()
|
||||||
|
_state.update { AccountUiState(isLoggedIn = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAccount() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true) }
|
||||||
|
userRepo.deleteAccount().fold(
|
||||||
|
onSuccess = {
|
||||||
|
authStorage.clear()
|
||||||
|
_state.update { AccountUiState(isLoggedIn = false) }
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(isLoading = false, error = e.message ?: "Delete failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportData() {
|
||||||
|
// TODO: implement data export and save/share the JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadProfile() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
userRepo.getProfile().onSuccess { profile ->
|
||||||
|
_state.update { it.copy(profile = profile) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
package com.payfrit.food.ui.screens.alternatives
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.payfrit.food.data.model.*
|
||||||
|
import com.payfrit.food.ui.theme.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AlternativesScreen(
|
||||||
|
productId: Int,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: AlternativesViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(productId) {
|
||||||
|
viewModel.loadAlternatives(productId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Healthier Alternatives") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
state.error != null -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(state.error!!, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// Filter chips
|
||||||
|
item {
|
||||||
|
FilterChipsRow(
|
||||||
|
filters = state.filters,
|
||||||
|
onFiltersChanged = { viewModel.updateFilters(it, productId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original product comparison bar
|
||||||
|
state.scannedProduct?.let { original ->
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Your product: ",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
original.name,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Score: ${original.overallScore}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(state.alternatives) { alt ->
|
||||||
|
AlternativeCard(alternative = alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.alternatives.isEmpty() && !state.isLoading) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("No alternatives found with current filters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AlternativeCard(alternative: Alternative) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val borderColor = if (alternative.isSponsored) SponsoredBorder else Color.Transparent
|
||||||
|
val containerColor = if (alternative.isSponsored) SponsoredGold else MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
border = if (alternative.isSponsored) BorderStroke(1.dp, borderColor) else null,
|
||||||
|
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
// Sponsored badge
|
||||||
|
if (alternative.isSponsored) {
|
||||||
|
Text(
|
||||||
|
"SPONSORED",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = SponsoredBorder,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
AsyncImage(
|
||||||
|
model = alternative.imageUrl,
|
||||||
|
contentDescription = alternative.name,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.padding(end = 12.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
alternative.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
alternative.brand,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score
|
||||||
|
alternative.rating?.let { rating ->
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
"${rating.overallScore}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(gradeColor(rating.grade)),
|
||||||
|
)
|
||||||
|
if (alternative.novaGroup > 0) {
|
||||||
|
Surface(
|
||||||
|
color = Color(novaColor(alternative.novaGroup)),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"N${alternative.novaGroup}",
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sponsor details (price, distance, delivery)
|
||||||
|
alternative.sponsor?.let { sponsor ->
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Divider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
sponsor.price?.let { price ->
|
||||||
|
val displayPrice = sponsor.salePrice?.let { sale ->
|
||||||
|
"$${String.format("%.2f", sale)} (was $${String.format("%.2f", price)})"
|
||||||
|
} ?: "$${String.format("%.2f", price)}"
|
||||||
|
Text(displayPrice, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
sponsor.distanceMiles?.let { dist ->
|
||||||
|
Text("${String.format("%.1f", dist)} mi", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
sponsor.purchaseUrl?.let { url ->
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ShoppingCart, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Buy", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alternative.delivery?.let { delivery ->
|
||||||
|
if (delivery.available) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
delivery.orderUrl?.let { url ->
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.LocalShipping, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Deliver", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alternative.pickup?.let { pickup ->
|
||||||
|
if (pickup.available) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Store, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Pickup", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun FilterChipsRow(
|
||||||
|
filters: AlternativeFilters,
|
||||||
|
onFiltersChanged: (AlternativeFilters) -> Unit,
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
// Sort
|
||||||
|
FilterChip(
|
||||||
|
selected = false,
|
||||||
|
onClick = {
|
||||||
|
val next = when (filters.sort) {
|
||||||
|
SortOption.RATING -> SortOption.PRICE
|
||||||
|
SortOption.PRICE -> SortOption.NOVA
|
||||||
|
SortOption.NOVA -> SortOption.RATING
|
||||||
|
}
|
||||||
|
onFiltersChanged(filters.copy(sort = next))
|
||||||
|
},
|
||||||
|
label = { Text("Sort: ${filters.sort.name}") },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dietary filters
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.organic,
|
||||||
|
onClick = { onFiltersChanged(filters.copy(organic = !filters.organic)) },
|
||||||
|
label = { Text("Organic") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.vegan,
|
||||||
|
onClick = { onFiltersChanged(filters.copy(vegan = !filters.vegan)) },
|
||||||
|
label = { Text("Vegan") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.glutenFree,
|
||||||
|
onClick = { onFiltersChanged(filters.copy(glutenFree = !filters.glutenFree)) },
|
||||||
|
label = { Text("Gluten-Free") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.dairyFree,
|
||||||
|
onClick = { onFiltersChanged(filters.copy(dairyFree = !filters.dairyFree)) },
|
||||||
|
label = { Text("Dairy-Free") },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fulfillment
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.delivery,
|
||||||
|
onClick = { onFiltersChanged(filters.copy(delivery = !filters.delivery)) },
|
||||||
|
label = { Text("Delivery") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.pickup,
|
||||||
|
onClick = { onFiltersChanged(filters.copy(pickup = !filters.pickup)) },
|
||||||
|
label = { Text("Pickup") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.payfrit.food.ui.screens.alternatives
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.payfrit.food.PayfritFoodApp
|
||||||
|
import com.payfrit.food.data.model.*
|
||||||
|
import com.payfrit.food.data.repository.ProductRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class AlternativesUiState(
|
||||||
|
val alternatives: List<Alternative> = emptyList(),
|
||||||
|
val scannedProduct: ScannedProductSummary? = null,
|
||||||
|
val filters: AlternativeFilters = AlternativeFilters(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AlternativesViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val repo = ProductRepository(PayfritFoodApp.instance.apiClient.api)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AlternativesUiState())
|
||||||
|
val state: StateFlow<AlternativesUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun loadAlternatives(productId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
repo.getAlternatives(productId, _state.value.filters).fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
alternatives = response.alternatives,
|
||||||
|
scannedProduct = response.scannedProduct,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(isLoading = false, error = e.message ?: "Failed to load alternatives")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFilters(newFilters: AlternativeFilters, productId: Int) {
|
||||||
|
_state.update { it.copy(filters = newFilters) }
|
||||||
|
loadAlternatives(productId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package com.payfrit.food.ui.screens.favorites
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.payfrit.food.ui.components.ProductCard
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FavoritesScreen(
|
||||||
|
onProductClick: (String) -> Unit,
|
||||||
|
viewModel: FavoritesViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { viewModel.loadFavorites() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text("Favorites") })
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
state.favorites.isEmpty() -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.FavoriteBorder,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"No favorites yet",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Scan a product and tap the heart to save it",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(state.favorites) { fav ->
|
||||||
|
ProductCard(
|
||||||
|
name = fav.name,
|
||||||
|
brand = fav.brand,
|
||||||
|
score = fav.overallScore,
|
||||||
|
grade = fav.grade,
|
||||||
|
novaGroup = fav.novaGroup,
|
||||||
|
imageUrl = fav.imageUrl,
|
||||||
|
subtitle = "Saved ${fav.savedOn?.take(10) ?: ""}",
|
||||||
|
onClick = { onProductClick(fav.barcode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.payfrit.food.ui.screens.favorites
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.payfrit.food.PayfritFoodApp
|
||||||
|
import com.payfrit.food.data.model.FavoriteItem
|
||||||
|
import com.payfrit.food.data.repository.UserRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class FavoritesUiState(
|
||||||
|
val favorites: List<FavoriteItem> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class FavoritesViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(FavoritesUiState())
|
||||||
|
val state: StateFlow<FavoritesUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun loadFavorites() {
|
||||||
|
if (!PayfritFoodApp.instance.authStorage.isLoggedIn) {
|
||||||
|
_state.update { it.copy(favorites = emptyList(), isLoading = false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true) }
|
||||||
|
userRepo.getFavorites().fold(
|
||||||
|
onSuccess = { favs ->
|
||||||
|
_state.update { it.copy(favorites = favs, isLoading = false) }
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
_state.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package com.payfrit.food.ui.screens.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.payfrit.food.ui.components.ProductCard
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HistoryScreen(
|
||||||
|
onProductClick: (String) -> Unit,
|
||||||
|
viewModel: HistoryViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { viewModel.loadHistory() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text("Scan History") })
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
state.history.isEmpty() -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.History,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("No scans yet", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
"Scan a barcode to see your history",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(state.history) { scan ->
|
||||||
|
ProductCard(
|
||||||
|
name = scan.name,
|
||||||
|
brand = scan.brand,
|
||||||
|
score = scan.overallScore,
|
||||||
|
grade = scan.grade,
|
||||||
|
novaGroup = scan.novaGroup,
|
||||||
|
imageUrl = scan.imageUrl,
|
||||||
|
subtitle = scan.scannedOn?.take(10),
|
||||||
|
onClick = { onProductClick(scan.barcode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.payfrit.food.ui.screens.history
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.payfrit.food.PayfritFoodApp
|
||||||
|
import com.payfrit.food.data.model.ScanHistoryItem
|
||||||
|
import com.payfrit.food.data.repository.UserRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class HistoryUiState(
|
||||||
|
val history: List<ScanHistoryItem> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class HistoryViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(HistoryUiState())
|
||||||
|
val state: StateFlow<HistoryUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun loadHistory() {
|
||||||
|
if (!PayfritFoodApp.instance.authStorage.isLoggedIn) {
|
||||||
|
_state.update { it.copy(history = emptyList(), isLoading = false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true) }
|
||||||
|
userRepo.getScanHistory().fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
_state.update { it.copy(history = response.scans, isLoading = false) }
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
_state.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
package com.payfrit.food.ui.screens.product
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.payfrit.food.data.model.Product
|
||||||
|
import com.payfrit.food.ui.components.NOVABadge
|
||||||
|
import com.payfrit.food.ui.components.ScoreRing
|
||||||
|
import com.payfrit.food.ui.components.DietaryPills
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProductScreen(
|
||||||
|
barcode: String,
|
||||||
|
onViewAlternatives: (Int) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: ProductViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(barcode) {
|
||||||
|
viewModel.loadProduct(barcode)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Product Details") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.toggleFavorite() }) {
|
||||||
|
Icon(
|
||||||
|
if (state.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||||
|
contentDescription = "Favorite",
|
||||||
|
tint = if (state.isFavorite) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.error != null -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ErrorOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(state.error!!, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(onClick = { viewModel.loadProduct(barcode) }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.product != null -> {
|
||||||
|
ProductContent(
|
||||||
|
product = state.product!!,
|
||||||
|
onViewAlternatives = onViewAlternatives,
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProductContent(
|
||||||
|
product: Product,
|
||||||
|
onViewAlternatives: (Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Image + Name
|
||||||
|
product.imageUrl?.let { url ->
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = product.name,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
product.name,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
product.brand,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Score + NOVA row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
product.rating?.let { rating ->
|
||||||
|
ScoreRing(
|
||||||
|
score = rating.overallScore,
|
||||||
|
grade = rating.grade,
|
||||||
|
label = "Health Score",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (product.novaGroup > 0) {
|
||||||
|
NOVABadge(novaGroup = product.novaGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Sub-scores
|
||||||
|
product.rating?.let { rating ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("Score Breakdown", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
ScoreRow("Nutrition", rating.nutritionScore)
|
||||||
|
ScoreRow("Ingredients", rating.ingredientScore)
|
||||||
|
ScoreRow("Overall", rating.overallScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dietary tags
|
||||||
|
product.dietary?.let { dietary ->
|
||||||
|
val tags = dietary.toTagList()
|
||||||
|
if (tags.isNotEmpty()) {
|
||||||
|
DietaryPills(tags = tags)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nutrition
|
||||||
|
product.nutrition?.let { nutrition ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("Nutrition Facts", style = MaterialTheme.typography.titleSmall)
|
||||||
|
nutrition.servingSize?.let {
|
||||||
|
Text("Serving: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
NutritionRow("Calories", "${nutrition.calories.toInt()}")
|
||||||
|
NutritionRow("Fat", "${nutrition.fat}g")
|
||||||
|
NutritionRow("Saturated Fat", "${nutrition.saturatedFat}g")
|
||||||
|
NutritionRow("Trans Fat", "${nutrition.transFat}g")
|
||||||
|
NutritionRow("Carbohydrates", "${nutrition.carbohydrates}g")
|
||||||
|
NutritionRow("Sugars", "${nutrition.sugars}g")
|
||||||
|
NutritionRow("Fiber", "${nutrition.fiber}g")
|
||||||
|
NutritionRow("Protein", "${nutrition.protein}g")
|
||||||
|
NutritionRow("Sodium", "${nutrition.sodium}mg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingredients
|
||||||
|
product.ingredients?.let { ingredients ->
|
||||||
|
if (ingredients.isNotBlank()) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("Ingredients", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(ingredients, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternatives button
|
||||||
|
Button(
|
||||||
|
onClick = { onViewAlternatives(product.id) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.SwapHoriz, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Find Healthier Alternatives")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScoreRow(label: String, score: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text("$score/100", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NutritionRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Text(value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.payfrit.food.ui.screens.product
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.payfrit.food.PayfritFoodApp
|
||||||
|
import com.payfrit.food.data.model.Product
|
||||||
|
import com.payfrit.food.data.repository.ProductRepository
|
||||||
|
import com.payfrit.food.data.repository.UserRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class ProductUiState(
|
||||||
|
val product: Product? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val isFavorite: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ProductViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val productRepo = ProductRepository(PayfritFoodApp.instance.apiClient.api)
|
||||||
|
private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ProductUiState())
|
||||||
|
val state: StateFlow<ProductUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun loadProduct(barcode: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
productRepo.lookupByBarcode(barcode).fold(
|
||||||
|
onSuccess = { product ->
|
||||||
|
_state.update { it.copy(product = product, isLoading = false) }
|
||||||
|
checkFavoriteStatus(product.id)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Product not found"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkFavoriteStatus(productId: Int) {
|
||||||
|
if (!PayfritFoodApp.instance.authStorage.isLoggedIn) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
userRepo.getFavorites().onSuccess { favorites ->
|
||||||
|
val isFav = favorites.any { it.productId == productId }
|
||||||
|
_state.update { it.copy(isFavorite = isFav) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFavorite() {
|
||||||
|
val product = _state.value.product ?: return
|
||||||
|
if (!PayfritFoodApp.instance.authStorage.isLoggedIn) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (_state.value.isFavorite) {
|
||||||
|
userRepo.removeFavorite(product.id).onSuccess {
|
||||||
|
_state.update { it.copy(isFavorite = false) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userRepo.addFavorite(product.id).onSuccess {
|
||||||
|
_state.update { it.copy(isFavorite = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
254
app/src/main/java/com/payfrit/food/ui/screens/scan/ScanScreen.kt
Normal file
254
app/src/main/java/com/payfrit/food/ui/screens/scan/ScanScreen.kt
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
package com.payfrit.food.ui.screens.scan
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||||
|
import com.google.mlkit.vision.barcode.common.Barcode
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ScanScreen(onProductScanned: (String) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var hasCameraPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var showManualEntry by remember { mutableStateOf(false) }
|
||||||
|
var manualBarcode by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted -> hasCameraPermission = granted }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!hasCameraPermission) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (hasCameraPermission) {
|
||||||
|
BarcodeCameraView(
|
||||||
|
onBarcodeDetected = { barcode ->
|
||||||
|
onProductScanned(barcode)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// No camera permission UI
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.QrCodeScanner,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Camera permission is required to scan barcodes",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
|
||||||
|
Text("Grant Permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan overlay hint
|
||||||
|
if (hasCameraPermission) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 80.dp)
|
||||||
|
.background(
|
||||||
|
Color.Black.copy(alpha = 0.6f),
|
||||||
|
RoundedCornerShape(24.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Point your camera at a barcode",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual entry FAB
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { showManualEntry = !showManualEntry },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(24.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Manual entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual entry bottom sheet
|
||||||
|
if (showManualEntry) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"Enter Barcode",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manualBarcode,
|
||||||
|
onValueChange = { manualBarcode = it.filter { c -> c.isDigit() } },
|
||||||
|
label = { Text("Barcode number") },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
if (manualBarcode.isNotBlank()) {
|
||||||
|
onProductScanned(manualBarcode)
|
||||||
|
showManualEntry = false
|
||||||
|
manualBarcode = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (manualBarcode.isNotBlank()) {
|
||||||
|
onProductScanned(manualBarcode)
|
||||||
|
showManualEntry = false
|
||||||
|
manualBarcode = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Look Up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||||
|
fun BarcodeCameraView(onBarcodeDetected: (String) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
var lastScannedBarcode by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
PreviewView(ctx).also { previewView ->
|
||||||
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||||
|
cameraProviderFuture.addListener({
|
||||||
|
val cameraProvider = cameraProviderFuture.get()
|
||||||
|
|
||||||
|
val preview = Preview.Builder().build().also {
|
||||||
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
val barcodeScanner = BarcodeScanning.getClient()
|
||||||
|
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
val imageAnalysis = ImageAnalysis.Builder()
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy ->
|
||||||
|
val mediaImage = imageProxy.image
|
||||||
|
if (mediaImage != null) {
|
||||||
|
val inputImage = InputImage.fromMediaImage(
|
||||||
|
mediaImage,
|
||||||
|
imageProxy.imageInfo.rotationDegrees
|
||||||
|
)
|
||||||
|
barcodeScanner.process(inputImage)
|
||||||
|
.addOnSuccessListener { barcodes ->
|
||||||
|
for (barcode in barcodes) {
|
||||||
|
val value = barcode.rawValue ?: continue
|
||||||
|
val format = barcode.format
|
||||||
|
// Only accept product barcodes
|
||||||
|
if (format in listOf(
|
||||||
|
Barcode.FORMAT_EAN_13,
|
||||||
|
Barcode.FORMAT_EAN_8,
|
||||||
|
Barcode.FORMAT_UPC_A,
|
||||||
|
Barcode.FORMAT_UPC_E,
|
||||||
|
Barcode.FORMAT_CODE_128,
|
||||||
|
) && value != lastScannedBarcode
|
||||||
|
) {
|
||||||
|
lastScannedBarcode = value
|
||||||
|
onBarcodeDetected(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnCompleteListener { imageProxy.close() }
|
||||||
|
} else {
|
||||||
|
imageProxy.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cameraProvider.unbindAll()
|
||||||
|
cameraProvider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
|
preview,
|
||||||
|
imageAnalysis
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Camera init failed
|
||||||
|
}
|
||||||
|
}, ContextCompat.getMainExecutor(ctx))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.payfrit.food.ui.screens.scan
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.payfrit.food.PayfritFoodApp
|
||||||
|
import com.payfrit.food.data.model.Product
|
||||||
|
import com.payfrit.food.data.repository.ProductRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ScanViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val repo = ProductRepository(PayfritFoodApp.instance.apiClient.api)
|
||||||
|
|
||||||
|
private val _scanState = MutableStateFlow<ScanState>(ScanState.Idle)
|
||||||
|
val scanState: StateFlow<ScanState> = _scanState.asStateFlow()
|
||||||
|
|
||||||
|
fun lookupBarcode(barcode: String) {
|
||||||
|
if (_scanState.value is ScanState.Loading) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_scanState.value = ScanState.Loading
|
||||||
|
repo.lookupByBarcode(barcode).fold(
|
||||||
|
onSuccess = { product ->
|
||||||
|
_scanState.value = ScanState.Found(product)
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
_scanState.value = ScanState.Error(
|
||||||
|
error.message ?: "Product not found"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
_scanState.value = ScanState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ScanState {
|
||||||
|
data object Idle : ScanState()
|
||||||
|
data object Loading : ScanState()
|
||||||
|
data class Found(val product: Product) : ScanState()
|
||||||
|
data class Error(val message: String) : ScanState()
|
||||||
|
}
|
||||||
29
app/src/main/java/com/payfrit/food/ui/theme/Color.kt
Normal file
29
app/src/main/java/com/payfrit/food/ui/theme/Color.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.payfrit.food.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Brand colors
|
||||||
|
val PayfritGreen = Color(0xFF4CAF50)
|
||||||
|
val PayfritDarkGreen = Color(0xFF2E7D32)
|
||||||
|
val PayfritLightGreen = Color(0xFFC8E6C9)
|
||||||
|
|
||||||
|
// Score grade colors
|
||||||
|
val GradeExcellent = Color(0xFF2E7D32)
|
||||||
|
val GradeGood = Color(0xFF4CAF50)
|
||||||
|
val GradeFair = Color(0xFFFF9800)
|
||||||
|
val GradePoor = Color(0xFFF44336)
|
||||||
|
|
||||||
|
// NOVA colors
|
||||||
|
val Nova1 = Color(0xFF4CAF50)
|
||||||
|
val Nova2 = Color(0xFFFFC107)
|
||||||
|
val Nova3 = Color(0xFFFF9800)
|
||||||
|
val Nova4 = Color(0xFFF44336)
|
||||||
|
|
||||||
|
// Neutral
|
||||||
|
val SurfaceLight = Color(0xFFFAFAFA)
|
||||||
|
val SurfaceDark = Color(0xFF121212)
|
||||||
|
val OnSurfaceVariant = Color(0xFF757575)
|
||||||
|
|
||||||
|
// Sponsor highlight
|
||||||
|
val SponsoredGold = Color(0xFFFFF3E0)
|
||||||
|
val SponsoredBorder = Color(0xFFFFB74D)
|
||||||
66
app/src/main/java/com/payfrit/food/ui/theme/Theme.kt
Normal file
66
app/src/main/java/com/payfrit/food/ui/theme/Theme.kt
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.payfrit.food.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = PayfritGreen,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = PayfritLightGreen,
|
||||||
|
onPrimaryContainer = PayfritDarkGreen,
|
||||||
|
secondary = Color(0xFF546E7A),
|
||||||
|
onSecondary = Color.White,
|
||||||
|
background = SurfaceLight,
|
||||||
|
surface = Color.White,
|
||||||
|
onBackground = Color(0xFF1C1B1F),
|
||||||
|
onSurface = Color(0xFF1C1B1F),
|
||||||
|
surfaceVariant = Color(0xFFF5F5F5),
|
||||||
|
onSurfaceVariant = OnSurfaceVariant,
|
||||||
|
outline = Color(0xFFBDBDBD),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = PayfritGreen,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = PayfritDarkGreen,
|
||||||
|
onPrimaryContainer = PayfritLightGreen,
|
||||||
|
secondary = Color(0xFF90A4AE),
|
||||||
|
onSecondary = Color.Black,
|
||||||
|
background = SurfaceDark,
|
||||||
|
surface = Color(0xFF1E1E1E),
|
||||||
|
onBackground = Color(0xFFE6E1E5),
|
||||||
|
onSurface = Color(0xFFE6E1E5),
|
||||||
|
surfaceVariant = Color(0xFF2C2C2C),
|
||||||
|
onSurfaceVariant = Color(0xFFBDBDBD),
|
||||||
|
outline = Color(0xFF616161),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PayfritFoodTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
window.statusBarColor = Color.Transparent.toArgb()
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography(),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
28
app/src/main/res/values/strings.xml
Normal file
28
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Payfrit Food</string>
|
||||||
|
<string name="tab_scan">Scan</string>
|
||||||
|
<string name="tab_favorites">Favorites</string>
|
||||||
|
<string name="tab_history">History</string>
|
||||||
|
<string name="tab_account">Account</string>
|
||||||
|
<string name="scan_prompt">Point your camera at a barcode</string>
|
||||||
|
<string name="manual_entry">Enter barcode manually</string>
|
||||||
|
<string name="no_product_found">Product not found</string>
|
||||||
|
<string name="loading">Loading…</string>
|
||||||
|
<string name="error_network">Network error. Check your connection.</string>
|
||||||
|
<string name="login">Log In</string>
|
||||||
|
<string name="register">Create Account</string>
|
||||||
|
<string name="logout">Log Out</string>
|
||||||
|
<string name="delete_account">Delete Account</string>
|
||||||
|
<string name="export_data">Export My Data</string>
|
||||||
|
<string name="alternatives_title">Healthier Alternatives</string>
|
||||||
|
<string name="sponsored">Sponsored</string>
|
||||||
|
<string name="score_label">Health Score</string>
|
||||||
|
<string name="nova_label">NOVA Group</string>
|
||||||
|
<string name="nutrition_label">Nutrition Facts</string>
|
||||||
|
<string name="ingredients_label">Ingredients</string>
|
||||||
|
<string name="dietary_label">Dietary Info</string>
|
||||||
|
<string name="add_favorite">Save to Favorites</string>
|
||||||
|
<string name="remove_favorite">Remove from Favorites</string>
|
||||||
|
<string name="camera_permission_required">Camera permission is required to scan barcodes</string>
|
||||||
|
</resources>
|
||||||
7
app/src/main/res/values/themes.xml
Normal file
7
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.PayfritFood" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/network_security_config.xml
Normal file
13
app/src/main/res/xml/network_security_config.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
<debug-overrides>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</debug-overrides>
|
||||||
|
</network-security-config>
|
||||||
5
build.gradle.kts
Normal file
5
build.gradle.kts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.2.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22" apply false
|
||||||
|
}
|
||||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
17
settings.gradle.kts
Normal file
17
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolution {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "PayfritFood"
|
||||||
|
include(":app")
|
||||||
Loading…
Add table
Reference in a new issue