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