commit 9496e1aff4e264a5045ec28b2654844baeb2dfef Author: Koda Date: Fri Mar 27 04:20:10 2026 +0000 feat: initial Payfrit Food Android app — barcode scanner, health scores, alternatives, favorites, history, account diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bc5985 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..632e840 --- /dev/null +++ b/CLAUDE.md @@ -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 ` 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..2b960aa --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..d833eed --- /dev/null +++ b/app/proguard-rules.pro @@ -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.* ; } +-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.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7802c2a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/payfrit/food/MainActivity.kt b/app/src/main/java/com/payfrit/food/MainActivity.kt new file mode 100644 index 0000000..d703da0 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/MainActivity.kt @@ -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() + } + } + } + } +} diff --git a/app/src/main/java/com/payfrit/food/PayfritFoodApp.kt b/app/src/main/java/com/payfrit/food/PayfritFoodApp.kt new file mode 100644 index 0000000..87b8194 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/PayfritFoodApp.kt @@ -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 + } +} diff --git a/app/src/main/java/com/payfrit/food/data/local/AuthStorage.kt b/app/src/main/java/com/payfrit/food/data/local/AuthStorage.kt new file mode 100644 index 0000000..d19790d --- /dev/null +++ b/app/src/main/java/com/payfrit/food/data/local/AuthStorage.kt @@ -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" + } +} diff --git a/app/src/main/java/com/payfrit/food/data/model/Models.kt b/app/src/main/java/com/payfrit/food/data/model/Models.kt new file mode 100644 index 0000000..6168100 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/data/model/Models.kt @@ -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 = 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, + @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, + @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, + @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 = 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 +} diff --git a/app/src/main/java/com/payfrit/food/data/remote/FoodApiClient.kt b/app/src/main/java/com/payfrit/food/data/remote/FoodApiClient.kt new file mode 100644 index 0000000..d3a9f87 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/data/remote/FoodApiClient.kt @@ -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 + + @POST("user/favorites.php") + suspend fun addFavorite(@Body body: Map): 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) +} diff --git a/app/src/main/java/com/payfrit/food/data/repository/ProductRepository.kt b/app/src/main/java/com/payfrit/food/data/repository/ProductRepository.kt new file mode 100644 index 0000000..a713a85 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/data/repository/ProductRepository.kt @@ -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 = safeCall { + api.lookupProduct(barcode) + } + + suspend fun search(query: String, limit: Int = 20): Result = safeCall { + api.searchProducts(query, limit) + } + + suspend fun getAlternatives( + productId: Int, + filters: AlternativeFilters = AlternativeFilters(), + lat: Double? = null, + lng: Double? = null, + ): Result = 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 = safeCall { + api.login(LoginRequest(username = email, password = password)) + } + + suspend fun register( + email: String, + password: String, + firstName: String, + lastName: String, + ): Result = safeCall { + api.register(RegisterRequest(email, password, firstName, lastName)) + } + + suspend fun getProfile(): Result = safeCall { + api.getAccount() + } + + suspend fun deleteAccount(): Result = safeCall { + api.deleteAccount() + } + + suspend fun getScanHistory(limit: Int = 50): Result = safeCall { + api.getScanHistory(limit) + } + + suspend fun getFavorites(): Result> = safeCall { + api.getFavorites() + } + + suspend fun addFavorite(productId: Int): Result = safeCall { + api.addFavorite(mapOf("ProductID" to productId)) + } + + suspend fun removeFavorite(productId: Int): Result = safeCall { + api.removeFavorite(productId) + } +} + +// Shared safe-call helper +internal suspend fun safeCall(block: suspend () -> T): Result = + withContext(Dispatchers.IO) { + try { + Result.success(block()) + } catch (e: Exception) { + Result.failure(e) + } + } diff --git a/app/src/main/java/com/payfrit/food/navigation/FoodNavHost.kt b/app/src/main/java/com/payfrit/food/navigation/FoodNavHost.kt new file mode 100644 index 0000000..25a2757 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/navigation/FoodNavHost.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/components/DietaryPills.kt b/app/src/main/java/com/payfrit/food/ui/components/DietaryPills.kt new file mode 100644 index 0000000..e10c85b --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/components/DietaryPills.kt @@ -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, + 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) }, + ) + } + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/components/NOVABadge.kt b/app/src/main/java/com/payfrit/food/ui/components/NOVABadge.kt new file mode 100644 index 0000000..259a815 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/components/NOVABadge.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/components/ProductCard.kt b/app/src/main/java/com/payfrit/food/ui/components/ProductCard.kt new file mode 100644 index 0000000..fdf9065 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/components/ProductCard.kt @@ -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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/components/ScoreRing.kt b/app/src/main/java/com/payfrit/food/ui/components/ScoreRing.kt new file mode 100644 index 0000000..53efae9 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/components/ScoreRing.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/account/AccountScreen.kt b/app/src/main/java/com/payfrit/food/ui/screens/account/AccountScreen.kt new file mode 100644 index 0000000..ae601c7 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/account/AccountScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/account/AccountViewModel.kt b/app/src/main/java/com/payfrit/food/ui/screens/account/AccountViewModel.kt new file mode 100644 index 0000000..1e35d2b --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/account/AccountViewModel.kt @@ -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 = _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) } + } + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/alternatives/AlternativesScreen.kt b/app/src/main/java/com/payfrit/food/ui/screens/alternatives/AlternativesScreen.kt new file mode 100644 index 0000000..73eb0dd --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/alternatives/AlternativesScreen.kt @@ -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") }, + ) + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/alternatives/AlternativesViewModel.kt b/app/src/main/java/com/payfrit/food/ui/screens/alternatives/AlternativesViewModel.kt new file mode 100644 index 0000000..50653b9 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/alternatives/AlternativesViewModel.kt @@ -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 = 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 = _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) + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/favorites/FavoritesScreen.kt b/app/src/main/java/com/payfrit/food/ui/screens/favorites/FavoritesScreen.kt new file mode 100644 index 0000000..d9cfa71 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/favorites/FavoritesScreen.kt @@ -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) }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/favorites/FavoritesViewModel.kt b/app/src/main/java/com/payfrit/food/ui/screens/favorites/FavoritesViewModel.kt new file mode 100644 index 0000000..dbf4f00 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/favorites/FavoritesViewModel.kt @@ -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 = emptyList(), + val isLoading: Boolean = false, +) + +class FavoritesViewModel : ViewModel() { + + private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api) + + private val _state = MutableStateFlow(FavoritesUiState()) + val state: StateFlow = _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) } + } + ) + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/history/HistoryScreen.kt b/app/src/main/java/com/payfrit/food/ui/screens/history/HistoryScreen.kt new file mode 100644 index 0000000..1c7dcad --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/history/HistoryScreen.kt @@ -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) }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/history/HistoryViewModel.kt b/app/src/main/java/com/payfrit/food/ui/screens/history/HistoryViewModel.kt new file mode 100644 index 0000000..fb13d83 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/history/HistoryViewModel.kt @@ -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 = emptyList(), + val isLoading: Boolean = false, +) + +class HistoryViewModel : ViewModel() { + + private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api) + + private val _state = MutableStateFlow(HistoryUiState()) + val state: StateFlow = _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) } + } + ) + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/product/ProductScreen.kt b/app/src/main/java/com/payfrit/food/ui/screens/product/ProductScreen.kt new file mode 100644 index 0000000..71f11bc --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/product/ProductScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/product/ProductViewModel.kt b/app/src/main/java/com/payfrit/food/ui/screens/product/ProductViewModel.kt new file mode 100644 index 0000000..0f477a1 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/product/ProductViewModel.kt @@ -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 = _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) } + } + } + } + } +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/scan/ScanScreen.kt b/app/src/main/java/com/payfrit/food/ui/screens/scan/ScanScreen.kt new file mode 100644 index 0000000..38c7223 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/scan/ScanScreen.kt @@ -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(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() + ) +} diff --git a/app/src/main/java/com/payfrit/food/ui/screens/scan/ScanViewModel.kt b/app/src/main/java/com/payfrit/food/ui/screens/scan/ScanViewModel.kt new file mode 100644 index 0000000..7a49981 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/screens/scan/ScanViewModel.kt @@ -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.Idle) + val scanState: StateFlow = _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() +} diff --git a/app/src/main/java/com/payfrit/food/ui/theme/Color.kt b/app/src/main/java/com/payfrit/food/ui/theme/Color.kt new file mode 100644 index 0000000..bdf65f4 --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/payfrit/food/ui/theme/Theme.kt b/app/src/main/java/com/payfrit/food/ui/theme/Theme.kt new file mode 100644 index 0000000..bc4d34e --- /dev/null +++ b/app/src/main/java/com/payfrit/food/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..029c360 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,28 @@ + + + Payfrit Food + Scan + Favorites + History + Account + Point your camera at a barcode + Enter barcode manually + Product not found + Loading… + Network error. Check your connection. + Log In + Create Account + Log Out + Delete Account + Export My Data + Healthier Alternatives + Sponsored + Health Score + NOVA Group + Nutrition Facts + Ingredients + Dietary Info + Save to Favorites + Remove from Favorites + Camera permission is required to scan barcodes + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2d22cc4 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..f06cb90 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..dfbdaeb --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7a44322 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolution { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "PayfritFood" +include(":app")