feat: initial Payfrit Food Android app — barcode scanner, health scores, alternatives, favorites, history, account

This commit is contained in:
Koda 2026-03-27 04:20:10 +00:00
commit 9496e1aff4
37 changed files with 3231 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
keystore.properties
*.jks
*.keystore
/app/release/
/app/debug/
*.apk
*.aab

69
CLAUDE.md Normal file
View file

@ -0,0 +1,69 @@
# Payfrit Food Android
Native Android app for the Payfrit Food product intelligence platform.
## What It Does
Users scan food product barcodes, get health scores (0-100), NOVA processing classification (1-4),
nutrition facts, ingredient analysis, and discover healthier alternatives from sponsors.
## Stack
- **Language**: Kotlin
- **UI**: Jetpack Compose + Material 3
- **Architecture**: MVVM (ViewModel + StateFlow)
- **Networking**: Retrofit 2 + kotlinx.serialization
- **Image Loading**: Coil
- **Barcode Scanning**: CameraX + ML Kit
- **Auth Storage**: EncryptedSharedPreferences (Jetpack Security)
- **Min SDK**: 26 (Android 8.0)
- **Target SDK**: 34
## API Base URLs
- **Production**: `https://food.payfrit.com/api`
- **Dev**: `https://dev.payfrit.com/api` (set via BuildConfig)
## Key Endpoints
- `GET /scan.php?barcode=X` — product lookup
- `GET /search.php?q=X` — product search
- `GET /alternatives.php?productID=X` — healthier alternatives
- `POST /user/login.php` — email/password login
- `POST /user/register.php` — registration
- `GET /user/account.php` — profile
- `GET /user/scans.php` — scan history
- `GET /user/favorites.php` — favorites list
- `POST /user/favorites.php` — add favorite
- `DELETE /user/favorites.php?productID=X` — remove favorite
- `DELETE /user/account.php` — delete account (GDPR)
## Auth
- Bearer token via `Authorization: Bearer <token>` header
- Token stored in EncryptedSharedPreferences
- Public endpoints (scan, search, alternatives) work without auth
- User endpoints (history, favorites, account) require auth
## Project Structure
```
com.payfrit.food/
├── PayfritFoodApp.kt # Application class
├── MainActivity.kt # Single activity
├── data/
│ ├── model/Models.kt # All data classes
│ ├── local/AuthStorage.kt # Encrypted token storage
│ ├── remote/FoodApiClient.kt # Retrofit API client
│ └── repository/ # ProductRepository, UserRepository
├── navigation/FoodNavHost.kt # Bottom tab + detail navigation
└── ui/
├── theme/ # Material 3 theme
├── components/ # ScoreRing, NOVABadge, DietaryPills, ProductCard
└── screens/
├── scan/ # Camera barcode scanner + manual entry
├── product/ # Product detail with scores, nutrition, ingredients
├── alternatives/ # Filtered alternatives list with sponsor cards
├── favorites/ # Saved products
├── history/ # Scan history
└── account/ # Login/register/profile/GDPR
```
## Building
- Keystore passwords in `keystore.properties` (gitignored)
- Debug builds point to dev API
- Release builds point to production API

124
app/build.gradle.kts Normal file
View file

@ -0,0 +1,124 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.payfrit.food"
compileSdk = 34
defaultConfig {
applicationId = "com.payfrit.food"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
signingConfigs {
create("release") {
val props = java.util.Properties()
val propsFile = rootProject.file("keystore.properties")
if (propsFile.exists()) {
props.load(propsFile.inputStream())
storeFile = file(props["storeFile"] as String)
storePassword = props["storePassword"] as String
keyAlias = props["keyAlias"] as String
keyPassword = props["keyPassword"] as String
}
}
}
buildTypes {
debug {
buildConfigField("String", "API_BASE_URL", "\"https://dev.payfrit.com/api\"")
buildConfigField("Boolean", "DEV_MODE", "true")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
buildConfigField("String", "API_BASE_URL", "\"https://food.payfrit.com/api\"")
buildConfigField("Boolean", "DEV_MODE", "false")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
}
}
dependencies {
// Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose BOM
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
// Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// Image loading
implementation("io.coil-kt:coil-compose:2.5.0")
// CameraX for barcode scanning
implementation("androidx.camera:camera-camera2:1.3.1")
implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view:1.3.1")
implementation("com.google.mlkit:barcode-scanning:17.2.0")
// Encrypted storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.9")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

25
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,25 @@
# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); }
-keep,includedescriptorclasses class com.payfrit.food.**$$serializer { *; }
-keepclassmembers class com.payfrit.food.** { *** Companion; }
-keepclasseswithmembers class com.payfrit.food.** { kotlinx.serialization.KSerializer serializer(...); }
# Retrofit
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * { @retrofit2.http.* <methods>; }
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# ML Kit
-keep class com.google.mlkit.** { *; }

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".PayfritFoodApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PayfritFood"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.PayfritFood"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,29 @@
package com.payfrit.food
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.payfrit.food.navigation.FoodNavHost
import com.payfrit.food.ui.theme.PayfritFoodTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PayfritFoodTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
FoodNavHost()
}
}
}
}
}

View file

@ -0,0 +1,27 @@
package com.payfrit.food
import android.app.Application
class PayfritFoodApp : Application() {
lateinit var apiClient: com.payfrit.food.data.remote.FoodApiClient
private set
lateinit var authStorage: com.payfrit.food.data.local.AuthStorage
private set
override fun onCreate() {
super.onCreate()
instance = this
authStorage = com.payfrit.food.data.local.AuthStorage(this)
apiClient = com.payfrit.food.data.remote.FoodApiClient(
baseUrl = BuildConfig.API_BASE_URL,
tokenProvider = { authStorage.getToken() }
)
}
companion object {
lateinit var instance: PayfritFoodApp
private set
}
}

View file

@ -0,0 +1,58 @@
package com.payfrit.food.data.local
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Secure token + user info storage using EncryptedSharedPreferences.
* Never stores credentials in plain SharedPreferences.
*/
class AuthStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"payfrit_food_auth",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String) {
prefs.edit().putString(KEY_TOKEN, token).apply()
}
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
fun saveUser(uuid: String, firstName: String, lastName: String, email: String) {
prefs.edit()
.putString(KEY_UUID, uuid)
.putString(KEY_FIRST_NAME, firstName)
.putString(KEY_LAST_NAME, lastName)
.putString(KEY_EMAIL, email)
.apply()
}
fun getUserUuid(): String? = prefs.getString(KEY_UUID, null)
fun getFirstName(): String? = prefs.getString(KEY_FIRST_NAME, null)
fun getLastName(): String? = prefs.getString(KEY_LAST_NAME, null)
fun getEmail(): String? = prefs.getString(KEY_EMAIL, null)
val isLoggedIn: Boolean get() = getToken() != null
fun clear() {
prefs.edit().clear().apply()
}
companion object {
private const val KEY_TOKEN = "auth_token"
private const val KEY_UUID = "user_uuid"
private const val KEY_FIRST_NAME = "first_name"
private const val KEY_LAST_NAME = "last_name"
private const val KEY_EMAIL = "email"
}
}

View file

@ -0,0 +1,283 @@
package com.payfrit.food.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// ── Product ──────────────────────────────────────────────────────────
@Serializable
data class Product(
@SerialName("ID") val id: Int,
@SerialName("Barcode") val barcode: String,
@SerialName("Name") val name: String,
@SerialName("Brand") val brand: String,
@SerialName("Description") val description: String? = null,
@SerialName("CategoryID") val categoryId: Int? = null,
@SerialName("CategoryName") val categoryName: String? = null,
@SerialName("ImageURL") val imageUrl: String? = null,
@SerialName("NovaGroup") val novaGroup: Int = 0,
@SerialName("Ingredients") val ingredients: String? = null,
@SerialName("Dietary") val dietary: DietaryInfo? = null,
@SerialName("Nutrition") val nutrition: NutritionInfo? = null,
@SerialName("Rating") val rating: Rating? = null,
)
@Serializable
data class DietaryInfo(
@SerialName("IsOrganic") val isOrganic: Boolean = false,
@SerialName("IsVegan") val isVegan: Boolean = false,
@SerialName("IsVegetarian") val isVegetarian: Boolean = false,
@SerialName("IsGlutenFree") val isGlutenFree: Boolean = false,
@SerialName("IsDairyFree") val isDairyFree: Boolean = false,
@SerialName("IsNutFree") val isNutFree: Boolean = false,
) {
fun toTagList(): List<String> = buildList {
if (isOrganic) add("Organic")
if (isVegan) add("Vegan")
if (isVegetarian) add("Vegetarian")
if (isGlutenFree) add("Gluten-Free")
if (isDairyFree) add("Dairy-Free")
if (isNutFree) add("Nut-Free")
}
}
@Serializable
data class NutritionInfo(
@SerialName("Calories") val calories: Double = 0.0,
@SerialName("Fat") val fat: Double = 0.0,
@SerialName("SaturatedFat") val saturatedFat: Double = 0.0,
@SerialName("TransFat") val transFat: Double = 0.0,
@SerialName("Carbohydrates") val carbohydrates: Double = 0.0,
@SerialName("Sugars") val sugars: Double = 0.0,
@SerialName("Fiber") val fiber: Double = 0.0,
@SerialName("Protein") val protein: Double = 0.0,
@SerialName("Sodium") val sodium: Double = 0.0,
@SerialName("ServingSize") val servingSize: String? = null,
)
@Serializable
data class Rating(
@SerialName("NutritionScore") val nutritionScore: Int = 0,
@SerialName("IngredientScore") val ingredientScore: Int = 0,
@SerialName("OverallScore") val overallScore: Int = 0,
@SerialName("Grade") val grade: String = "unknown",
)
// ── Alternative ──────────────────────────────────────────────────────
@Serializable
data class AlternativesResponse(
@SerialName("ScannedProduct") val scannedProduct: ScannedProductSummary,
@SerialName("Alternatives") val alternatives: List<Alternative>,
@SerialName("Count") val count: Int,
@SerialName("IsPremiumUser") val isPremiumUser: Boolean = false,
)
@Serializable
data class ScannedProductSummary(
@SerialName("ID") val id: Int,
@SerialName("Name") val name: String,
@SerialName("OverallScore") val overallScore: Int,
)
@Serializable
data class Alternative(
@SerialName("ID") val id: Int,
@SerialName("Barcode") val barcode: String,
@SerialName("Name") val name: String,
@SerialName("Brand") val brand: String,
@SerialName("ImageURL") val imageUrl: String? = null,
@SerialName("NovaGroup") val novaGroup: Int = 0,
@SerialName("Rating") val rating: Rating? = null,
@SerialName("Dietary") val dietary: DietaryInfo? = null,
@SerialName("IsSponsored") val isSponsored: Boolean = false,
@SerialName("Sponsor") val sponsor: SponsorInfo? = null,
@SerialName("Delivery") val delivery: DeliveryInfo? = null,
@SerialName("Pickup") val pickup: PickupInfo? = null,
)
@Serializable
data class SponsorInfo(
@SerialName("ID") val id: Int,
@SerialName("Name") val name: String,
@SerialName("Type") val type: String? = null,
@SerialName("Price") val price: Double? = null,
@SerialName("SalePrice") val salePrice: Double? = null,
@SerialName("InStock") val inStock: Boolean = true,
@SerialName("PurchaseURL") val purchaseUrl: String? = null,
@SerialName("StoreLocation") val storeLocation: String? = null,
@SerialName("DistanceMiles") val distanceMiles: Double? = null,
)
@Serializable
data class DeliveryInfo(
@SerialName("Available") val available: Boolean = false,
@SerialName("Fee") val fee: Double? = null,
@SerialName("FreeDelivery") val freeDelivery: Boolean = false,
@SerialName("Estimate") val estimate: String? = null,
@SerialName("OrderURL") val orderUrl: String? = null,
)
@Serializable
data class PickupInfo(
@SerialName("Available") val available: Boolean = false,
@SerialName("Estimate") val estimate: String? = null,
@SerialName("DistanceMiles") val distanceMiles: Double? = null,
)
// ── Search ───────────────────────────────────────────────────────────
@Serializable
data class SearchResponse(
@SerialName("Query") val query: String,
@SerialName("Results") val results: List<ProductSummary>,
@SerialName("Count") val count: Int,
)
@Serializable
data class ProductSummary(
@SerialName("ID") val id: Int,
@SerialName("Barcode") val barcode: String,
@SerialName("Name") val name: String,
@SerialName("Brand") val brand: String,
@SerialName("ImageURL") val imageUrl: String? = null,
@SerialName("OverallScore") val overallScore: Int = 0,
@SerialName("Grade") val grade: String = "unknown",
@SerialName("NovaGroup") val novaGroup: Int = 0,
@SerialName("CategoryName") val categoryName: String? = null,
)
// ── User / Auth ──────────────────────────────────────────────────────
@Serializable
data class AuthResponse(
val success: Boolean,
val message: String? = null,
val data: AuthData? = null,
)
@Serializable
data class AuthData(
@SerialName("UUID") val uuid: String,
@SerialName("Token") val token: String,
@SerialName("FirstName") val firstName: String? = null,
@SerialName("LastName") val lastName: String? = null,
)
@Serializable
data class UserProfile(
@SerialName("UUID") val uuid: String,
@SerialName("FirstName") val firstName: String,
@SerialName("LastName") val lastName: String,
@SerialName("EmailAddress") val email: String,
@SerialName("ZIPCode") val zipCode: String? = null,
@SerialName("IsPremium") val isPremium: Boolean = false,
@SerialName("ScanCount") val scanCount: Int = 0,
@SerialName("FavoriteCount") val favoriteCount: Int = 0,
@SerialName("MemberSince") val memberSince: String? = null,
)
@Serializable
data class LoginRequest(
val username: String,
val password: String,
)
@Serializable
data class RegisterRequest(
@SerialName("EmailAddress") val email: String,
@SerialName("Password") val password: String,
@SerialName("FirstName") val firstName: String,
@SerialName("LastName") val lastName: String,
)
// ── Scan History ─────────────────────────────────────────────────────
@Serializable
data class ScanHistoryResponse(
@SerialName("Scans") val scans: List<ScanHistoryItem>,
@SerialName("Count") val count: Int,
)
@Serializable
data class ScanHistoryItem(
@SerialName("ProductID") val productId: Int,
@SerialName("Barcode") val barcode: String,
@SerialName("Name") val name: String,
@SerialName("Brand") val brand: String,
@SerialName("ImageURL") val imageUrl: String? = null,
@SerialName("OverallScore") val overallScore: Int = 0,
@SerialName("Grade") val grade: String = "unknown",
@SerialName("NovaGroup") val novaGroup: Int = 0,
@SerialName("CategoryName") val categoryName: String? = null,
@SerialName("ScannedOn") val scannedOn: String? = null,
)
// ── Favorites ────────────────────────────────────────────────────────
@Serializable
data class FavoriteItem(
@SerialName("ProductID") val productId: Int,
@SerialName("Barcode") val barcode: String,
@SerialName("Name") val name: String,
@SerialName("Brand") val brand: String,
@SerialName("ImageURL") val imageUrl: String? = null,
@SerialName("OverallScore") val overallScore: Int = 0,
@SerialName("Grade") val grade: String = "unknown",
@SerialName("NovaGroup") val novaGroup: Int = 0,
@SerialName("SavedOn") val savedOn: String? = null,
)
@Serializable
data class SimpleResponse(
val success: Boolean,
val message: String? = null,
)
// ── Filter Options ───────────────────────────────────────────────────
data class AlternativeFilters(
val sort: SortOption = SortOption.RATING,
val order: SortOrder = SortOrder.DESC,
val novaLevels: Set<Int> = emptySet(),
val vegan: Boolean = false,
val vegetarian: Boolean = false,
val glutenFree: Boolean = false,
val dairyFree: Boolean = false,
val nutFree: Boolean = false,
val organic: Boolean = false,
val minPrice: Double? = null,
val maxPrice: Double? = null,
val delivery: Boolean = false,
val pickup: Boolean = false,
val sponsoredOnly: Boolean = false,
)
enum class SortOption(val apiValue: String) {
RATING("rating"),
PRICE("price"),
NOVA("nova"),
}
enum class SortOrder(val apiValue: String) {
ASC("asc"),
DESC("desc"),
}
// ── Grade helpers ────────────────────────────────────────────────────
fun gradeColor(grade: String): Long = when (grade.lowercase()) {
"excellent" -> 0xFF2E7D32 // dark green
"good" -> 0xFF4CAF50 // green
"fair" -> 0xFFFF9800 // orange
"poor" -> 0xFFF44336 // red
else -> 0xFF9E9E9E // gray
}
fun novaColor(nova: Int): Long = when (nova) {
1 -> 0xFF4CAF50 // green
2 -> 0xFFFFC107 // yellow
3 -> 0xFFFF9800 // orange
4 -> 0xFFF44336 // red
else -> 0xFF9E9E9E
}

View file

@ -0,0 +1,122 @@
package com.payfrit.food.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.payfrit.food.data.model.*
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.http.*
import java.util.concurrent.TimeUnit
// ── Retrofit service interface ───────────────────────────────────────
interface FoodApiService {
// Product lookup
@GET("scan.php")
suspend fun lookupProduct(@Query("barcode") barcode: String): Product
// Search
@GET("search.php")
suspend fun searchProducts(
@Query("q") query: String,
@Query("limit") limit: Int = 20,
): SearchResponse
// Alternatives
@GET("alternatives.php")
suspend fun getAlternatives(
@Query("productID") productId: Int,
@Query("sort") sort: String? = null,
@Query("order") order: String? = null,
@Query("lat") lat: Double? = null,
@Query("lng") lng: Double? = null,
@Query("vegan") vegan: Int? = null,
@Query("vegetarian") vegetarian: Int? = null,
@Query("glutenFree") glutenFree: Int? = null,
@Query("dairyFree") dairyFree: Int? = null,
@Query("nutFree") nutFree: Int? = null,
@Query("organic") organic: Int? = null,
@Query("nova") nova: String? = null,
@Query("minPrice") minPrice: Double? = null,
@Query("maxPrice") maxPrice: Double? = null,
@Query("delivery") delivery: Int? = null,
@Query("pickup") pickup: Int? = null,
@Query("sponsored") sponsored: Int? = null,
): AlternativesResponse
// Auth
@POST("user/login.php")
suspend fun login(@Body request: LoginRequest): AuthResponse
@POST("user/register.php")
suspend fun register(@Body request: RegisterRequest): AuthResponse
// Account
@GET("user/account.php")
suspend fun getAccount(): UserProfile
@DELETE("user/account.php")
suspend fun deleteAccount(): SimpleResponse
@POST("user/account.php?export=1")
suspend fun exportData(): kotlinx.serialization.json.JsonObject
// Scan history
@GET("user/scans.php")
suspend fun getScanHistory(@Query("limit") limit: Int = 50): ScanHistoryResponse
// Favorites
@GET("user/favorites.php")
suspend fun getFavorites(): List<FavoriteItem>
@POST("user/favorites.php")
suspend fun addFavorite(@Body body: Map<String, Int>): SimpleResponse
@DELETE("user/favorites.php")
suspend fun removeFavorite(@Query("productID") productId: Int): SimpleResponse
}
// ── Client wrapper ───────────────────────────────────────────────────
class FoodApiClient(
baseUrl: String,
private val tokenProvider: () -> String?,
) {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
private val authInterceptor = Interceptor { chain ->
val requestBuilder = chain.request().newBuilder()
tokenProvider()?.let { token ->
requestBuilder.addHeader("Authorization", "Bearer $token")
}
chain.proceed(requestBuilder.build())
}
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl(if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/")
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
val api: FoodApiService = retrofit.create(FoodApiService::class.java)
}

View file

@ -0,0 +1,97 @@
package com.payfrit.food.data.repository
import com.payfrit.food.data.model.*
import com.payfrit.food.data.remote.FoodApiService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProductRepository(private val api: FoodApiService) {
suspend fun lookupByBarcode(barcode: String): Result<Product> = safeCall {
api.lookupProduct(barcode)
}
suspend fun search(query: String, limit: Int = 20): Result<SearchResponse> = safeCall {
api.searchProducts(query, limit)
}
suspend fun getAlternatives(
productId: Int,
filters: AlternativeFilters = AlternativeFilters(),
lat: Double? = null,
lng: Double? = null,
): Result<AlternativesResponse> = safeCall {
api.getAlternatives(
productId = productId,
sort = filters.sort.apiValue,
order = filters.order.apiValue,
lat = lat,
lng = lng,
vegan = filters.vegan.toQueryInt(),
vegetarian = filters.vegetarian.toQueryInt(),
glutenFree = filters.glutenFree.toQueryInt(),
dairyFree = filters.dairyFree.toQueryInt(),
nutFree = filters.nutFree.toQueryInt(),
organic = filters.organic.toQueryInt(),
nova = filters.novaLevels.takeIf { it.isNotEmpty() }
?.joinToString(","),
minPrice = filters.minPrice,
maxPrice = filters.maxPrice,
delivery = filters.delivery.toQueryInt(),
pickup = filters.pickup.toQueryInt(),
sponsored = filters.sponsoredOnly.toQueryInt(),
)
}
private fun Boolean.toQueryInt(): Int? = if (this) 1 else null
}
class UserRepository(private val api: FoodApiService) {
suspend fun login(email: String, password: String): Result<AuthResponse> = safeCall {
api.login(LoginRequest(username = email, password = password))
}
suspend fun register(
email: String,
password: String,
firstName: String,
lastName: String,
): Result<AuthResponse> = safeCall {
api.register(RegisterRequest(email, password, firstName, lastName))
}
suspend fun getProfile(): Result<UserProfile> = safeCall {
api.getAccount()
}
suspend fun deleteAccount(): Result<SimpleResponse> = safeCall {
api.deleteAccount()
}
suspend fun getScanHistory(limit: Int = 50): Result<ScanHistoryResponse> = safeCall {
api.getScanHistory(limit)
}
suspend fun getFavorites(): Result<List<FavoriteItem>> = safeCall {
api.getFavorites()
}
suspend fun addFavorite(productId: Int): Result<SimpleResponse> = safeCall {
api.addFavorite(mapOf("ProductID" to productId))
}
suspend fun removeFavorite(productId: Int): Result<SimpleResponse> = safeCall {
api.removeFavorite(productId)
}
}
// Shared safe-call helper
internal suspend fun <T> safeCall(block: suspend () -> T): Result<T> =
withContext(Dispatchers.IO) {
try {
Result.success(block())
} catch (e: Exception) {
Result.failure(e)
}
}

View file

@ -0,0 +1,129 @@
package com.payfrit.food.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.payfrit.food.ui.screens.account.AccountScreen
import com.payfrit.food.ui.screens.alternatives.AlternativesScreen
import com.payfrit.food.ui.screens.favorites.FavoritesScreen
import com.payfrit.food.ui.screens.history.HistoryScreen
import com.payfrit.food.ui.screens.product.ProductScreen
import com.payfrit.food.ui.screens.scan.ScanScreen
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
data object Scan : Screen("scan", "Scan", Icons.Default.QrCodeScanner)
data object Favorites : Screen("favorites", "Favorites", Icons.Default.Favorite)
data object History : Screen("history", "History", Icons.Default.History)
data object Account : Screen("account", "Account", Icons.Default.Person)
}
val bottomTabs = listOf(Screen.Scan, Screen.Favorites, Screen.History, Screen.Account)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FoodNavHost() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
// Hide bottom bar on detail screens
val showBottomBar = bottomTabs.any { tab ->
currentDestination?.hierarchy?.any { it.route == tab.route } == true
}
Scaffold(
bottomBar = {
if (showBottomBar) {
NavigationBar {
bottomTabs.forEach { screen ->
NavigationBarItem(
icon = { Icon(screen.icon, contentDescription = screen.title) },
label = { Text(screen.title) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screen.Scan.route,
modifier = Modifier.padding(innerPadding),
) {
composable(Screen.Scan.route) {
ScanScreen(
onProductScanned = { barcode ->
navController.navigate("product/$barcode")
}
)
}
composable(
route = "product/{barcode}",
arguments = listOf(navArgument("barcode") { type = NavType.StringType })
) { backStackEntry ->
val barcode = backStackEntry.arguments?.getString("barcode") ?: return@composable
ProductScreen(
barcode = barcode,
onViewAlternatives = { productId ->
navController.navigate("alternatives/$productId")
},
onBack = { navController.popBackStack() }
)
}
composable(
route = "alternatives/{productId}",
arguments = listOf(navArgument("productId") { type = NavType.IntType })
) { backStackEntry ->
val productId = backStackEntry.arguments?.getInt("productId") ?: return@composable
AlternativesScreen(
productId = productId,
onBack = { navController.popBackStack() }
)
}
composable(Screen.Favorites.route) {
FavoritesScreen(
onProductClick = { barcode ->
navController.navigate("product/$barcode")
}
)
}
composable(Screen.History.route) {
HistoryScreen(
onProductClick = { barcode ->
navController.navigate("product/$barcode")
}
)
}
composable(Screen.Account.route) {
AccountScreen()
}
}
}
}

View file

@ -0,0 +1,32 @@
package com.payfrit.food.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DietaryPills(
tags: List<String>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Text(
"Dietary Info",
style = MaterialTheme.typography.titleSmall,
)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
tags.forEach { tag ->
SuggestionChip(
onClick = {},
label = { Text(tag, style = MaterialTheme.typography.labelSmall) },
)
}
}
}
}

View file

@ -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,
)
}
}

View file

@ -0,0 +1,112 @@
package com.payfrit.food.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.payfrit.food.data.model.gradeColor
import com.payfrit.food.data.model.novaColor
@Composable
fun ProductCard(
name: String,
brand: String,
score: Int,
grade: String,
novaGroup: Int,
imageUrl: String?,
subtitle: String? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Image
AsyncImage(
model = imageUrl,
contentDescription = name,
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
)
Spacer(modifier = Modifier.width(12.dp))
// Name + brand
Column(modifier = Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
brand,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
subtitle?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// Score + NOVA
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"$score",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(gradeColor(grade)),
)
Text(
grade.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.labelSmall,
color = Color(gradeColor(grade)),
)
if (novaGroup > 0) {
Spacer(modifier = Modifier.height(4.dp))
Surface(
color = Color(novaColor(novaGroup)),
shape = RoundedCornerShape(4.dp),
) {
Text(
"N$novaGroup",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
fontWeight = FontWeight.Bold,
)
}
}
}
}
}
}

View file

@ -0,0 +1,106 @@
package com.payfrit.food.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.payfrit.food.ui.theme.*
@Composable
fun ScoreRing(
score: Int,
grade: String,
label: String,
modifier: Modifier = Modifier,
size: Int = 100,
) {
var animationPlayed by remember { mutableStateOf(false) }
val animatedProgress by animateFloatAsState(
targetValue = if (animationPlayed) score / 100f else 0f,
animationSpec = tween(durationMillis = 1000),
label = "score_animation",
)
LaunchedEffect(score) { animationPlayed = true }
val ringColor = when (grade.lowercase()) {
"excellent" -> GradeExcellent
"good" -> GradeGood
"fair" -> GradeFair
"poor" -> GradePoor
else -> Color.Gray
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(size.dp),
) {
Canvas(modifier = Modifier.size(size.dp)) {
val strokeWidth = 10f
val arcSize = Size(this.size.width - strokeWidth, this.size.height - strokeWidth)
val topLeft = Offset(strokeWidth / 2, strokeWidth / 2)
// Background ring
drawArc(
color = Color.LightGray.copy(alpha = 0.3f),
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
)
// Score arc
drawArc(
color = ringColor,
startAngle = -90f,
sweepAngle = 360f * animatedProgress,
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
)
}
Text(
text = "$score",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = ringColor,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = grade.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = ringColor,
)
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,134 @@
package com.payfrit.food.ui.screens.account
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.payfrit.food.PayfritFoodApp
import com.payfrit.food.data.model.UserProfile
import com.payfrit.food.data.repository.UserRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AccountUiState(
val isLoggedIn: Boolean = false,
val profile: UserProfile? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
class AccountViewModel : ViewModel() {
private val authStorage = PayfritFoodApp.instance.authStorage
private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api)
private val _state = MutableStateFlow(
AccountUiState(isLoggedIn = authStorage.isLoggedIn)
)
val state: StateFlow<AccountUiState> = _state.asStateFlow()
init {
if (authStorage.isLoggedIn) loadProfile()
}
fun login(email: String, password: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
userRepo.login(email, password).fold(
onSuccess = { response ->
if (response.success && response.data != null) {
authStorage.saveToken(response.data.token)
authStorage.saveUser(
uuid = response.data.uuid,
firstName = response.data.firstName ?: "",
lastName = response.data.lastName ?: "",
email = email,
)
_state.update { it.copy(isLoggedIn = true, isLoading = false) }
loadProfile()
} else {
_state.update {
it.copy(
isLoading = false,
error = response.message ?: "Login failed"
)
}
}
},
onFailure = { e ->
_state.update {
it.copy(isLoading = false, error = e.message ?: "Network error")
}
}
)
}
}
fun register(email: String, password: String, firstName: String, lastName: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
userRepo.register(email, password, firstName, lastName).fold(
onSuccess = { response ->
if (response.success && response.data != null) {
authStorage.saveToken(response.data.token)
authStorage.saveUser(
uuid = response.data.uuid,
firstName = firstName,
lastName = lastName,
email = email,
)
_state.update { it.copy(isLoggedIn = true, isLoading = false) }
loadProfile()
} else {
_state.update {
it.copy(
isLoading = false,
error = response.message ?: "Registration failed"
)
}
}
},
onFailure = { e ->
_state.update {
it.copy(isLoading = false, error = e.message ?: "Network error")
}
}
)
}
}
fun logout() {
authStorage.clear()
_state.update { AccountUiState(isLoggedIn = false) }
}
fun deleteAccount() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
userRepo.deleteAccount().fold(
onSuccess = {
authStorage.clear()
_state.update { AccountUiState(isLoggedIn = false) }
},
onFailure = { e ->
_state.update {
it.copy(isLoading = false, error = e.message ?: "Delete failed")
}
}
)
}
}
fun exportData() {
// TODO: implement data export and save/share the JSON
}
private fun loadProfile() {
viewModelScope.launch {
userRepo.getProfile().onSuccess { profile ->
_state.update { it.copy(profile = profile) }
}
}
}
}

View file

@ -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") },
)
}
}

View file

@ -0,0 +1,55 @@
package com.payfrit.food.ui.screens.alternatives
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.payfrit.food.PayfritFoodApp
import com.payfrit.food.data.model.*
import com.payfrit.food.data.repository.ProductRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AlternativesUiState(
val alternatives: List<Alternative> = emptyList(),
val scannedProduct: ScannedProductSummary? = null,
val filters: AlternativeFilters = AlternativeFilters(),
val isLoading: Boolean = false,
val error: String? = null,
)
class AlternativesViewModel : ViewModel() {
private val repo = ProductRepository(PayfritFoodApp.instance.apiClient.api)
private val _state = MutableStateFlow(AlternativesUiState())
val state: StateFlow<AlternativesUiState> = _state.asStateFlow()
fun loadAlternatives(productId: Int) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repo.getAlternatives(productId, _state.value.filters).fold(
onSuccess = { response ->
_state.update {
it.copy(
alternatives = response.alternatives,
scannedProduct = response.scannedProduct,
isLoading = false,
)
}
},
onFailure = { e ->
_state.update {
it.copy(isLoading = false, error = e.message ?: "Failed to load alternatives")
}
}
)
}
}
fun updateFilters(newFilters: AlternativeFilters, productId: Int) {
_state.update { it.copy(filters = newFilters) }
loadAlternatives(productId)
}
}

View file

@ -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) },
)
}
}
}
}
}
}

View file

@ -0,0 +1,44 @@
package com.payfrit.food.ui.screens.favorites
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.payfrit.food.PayfritFoodApp
import com.payfrit.food.data.model.FavoriteItem
import com.payfrit.food.data.repository.UserRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class FavoritesUiState(
val favorites: List<FavoriteItem> = emptyList(),
val isLoading: Boolean = false,
)
class FavoritesViewModel : ViewModel() {
private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api)
private val _state = MutableStateFlow(FavoritesUiState())
val state: StateFlow<FavoritesUiState> = _state.asStateFlow()
fun loadFavorites() {
if (!PayfritFoodApp.instance.authStorage.isLoggedIn) {
_state.update { it.copy(favorites = emptyList(), isLoading = false) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
userRepo.getFavorites().fold(
onSuccess = { favs ->
_state.update { it.copy(favorites = favs, isLoading = false) }
},
onFailure = {
_state.update { it.copy(isLoading = false) }
}
)
}
}
}

View file

@ -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) },
)
}
}
}
}
}
}

View file

@ -0,0 +1,44 @@
package com.payfrit.food.ui.screens.history
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.payfrit.food.PayfritFoodApp
import com.payfrit.food.data.model.ScanHistoryItem
import com.payfrit.food.data.repository.UserRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class HistoryUiState(
val history: List<ScanHistoryItem> = emptyList(),
val isLoading: Boolean = false,
)
class HistoryViewModel : ViewModel() {
private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api)
private val _state = MutableStateFlow(HistoryUiState())
val state: StateFlow<HistoryUiState> = _state.asStateFlow()
fun loadHistory() {
if (!PayfritFoodApp.instance.authStorage.isLoggedIn) {
_state.update { it.copy(history = emptyList(), isLoading = false) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
userRepo.getScanHistory().fold(
onSuccess = { response ->
_state.update { it.copy(history = response.scans, isLoading = false) }
},
onFailure = {
_state.update { it.copy(isLoading = false) }
}
)
}
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,77 @@
package com.payfrit.food.ui.screens.product
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.payfrit.food.PayfritFoodApp
import com.payfrit.food.data.model.Product
import com.payfrit.food.data.repository.ProductRepository
import com.payfrit.food.data.repository.UserRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ProductUiState(
val product: Product? = null,
val isLoading: Boolean = false,
val error: String? = null,
val isFavorite: Boolean = false,
)
class ProductViewModel : ViewModel() {
private val productRepo = ProductRepository(PayfritFoodApp.instance.apiClient.api)
private val userRepo = UserRepository(PayfritFoodApp.instance.apiClient.api)
private val _state = MutableStateFlow(ProductUiState())
val state: StateFlow<ProductUiState> = _state.asStateFlow()
fun loadProduct(barcode: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
productRepo.lookupByBarcode(barcode).fold(
onSuccess = { product ->
_state.update { it.copy(product = product, isLoading = false) }
checkFavoriteStatus(product.id)
},
onFailure = { e ->
_state.update {
it.copy(
isLoading = false,
error = e.message ?: "Product not found"
)
}
}
)
}
}
private fun checkFavoriteStatus(productId: Int) {
if (!PayfritFoodApp.instance.authStorage.isLoggedIn) return
viewModelScope.launch {
userRepo.getFavorites().onSuccess { favorites ->
val isFav = favorites.any { it.productId == productId }
_state.update { it.copy(isFavorite = isFav) }
}
}
}
fun toggleFavorite() {
val product = _state.value.product ?: return
if (!PayfritFoodApp.instance.authStorage.isLoggedIn) return
viewModelScope.launch {
if (_state.value.isFavorite) {
userRepo.removeFavorite(product.id).onSuccess {
_state.update { it.copy(isFavorite = false) }
}
} else {
userRepo.addFavorite(product.id).onSuccess {
_state.update { it.copy(isFavorite = true) }
}
}
}
}
}

View file

@ -0,0 +1,254 @@
package com.payfrit.food.ui.screens.scan
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScanScreen(onProductScanned: (String) -> Unit) {
val context = LocalContext.current
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var showManualEntry by remember { mutableStateOf(false) }
var manualBarcode by remember { mutableStateOf("") }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasCameraPermission = granted }
LaunchedEffect(Unit) {
if (!hasCameraPermission) {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (hasCameraPermission) {
BarcodeCameraView(
onBarcodeDetected = { barcode ->
onProductScanned(barcode)
}
)
} else {
// No camera permission UI
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Camera permission is required to scan barcodes",
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
Text("Grant Permission")
}
}
}
// Scan overlay hint
if (hasCameraPermission) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 80.dp)
.background(
Color.Black.copy(alpha = 0.6f),
RoundedCornerShape(24.dp)
)
.padding(horizontal = 24.dp, vertical = 12.dp)
) {
Text(
"Point your camera at a barcode",
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
}
}
// Manual entry FAB
FloatingActionButton(
onClick = { showManualEntry = !showManualEntry },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(24.dp),
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(Icons.Default.Edit, contentDescription = "Manual entry")
}
// Manual entry bottom sheet
if (showManualEntry) {
Card(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"Enter Barcode",
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = manualBarcode,
onValueChange = { manualBarcode = it.filter { c -> c.isDigit() } },
label = { Text("Barcode number") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
if (manualBarcode.isNotBlank()) {
onProductScanned(manualBarcode)
showManualEntry = false
manualBarcode = ""
}
}
),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
if (manualBarcode.isNotBlank()) {
onProductScanned(manualBarcode)
showManualEntry = false
manualBarcode = ""
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Look Up")
}
}
}
}
}
}
@Composable
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
fun BarcodeCameraView(onBarcodeDetected: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var lastScannedBarcode by remember { mutableStateOf<String?>(null) }
AndroidView(
factory = { ctx ->
PreviewView(ctx).also { previewView ->
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val barcodeScanner = BarcodeScanning.getClient()
val analysisExecutor = Executors.newSingleThreadExecutor()
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage != null) {
val inputImage = InputImage.fromMediaImage(
mediaImage,
imageProxy.imageInfo.rotationDegrees
)
barcodeScanner.process(inputImage)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
val value = barcode.rawValue ?: continue
val format = barcode.format
// Only accept product barcodes
if (format in listOf(
Barcode.FORMAT_EAN_13,
Barcode.FORMAT_EAN_8,
Barcode.FORMAT_UPC_A,
Barcode.FORMAT_UPC_E,
Barcode.FORMAT_CODE_128,
) && value != lastScannedBarcode
) {
lastScannedBarcode = value
onBarcodeDetected(value)
}
}
}
.addOnCompleteListener { imageProxy.close() }
} else {
imageProxy.close()
}
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis
)
} catch (_: Exception) {
// Camera init failed
}
}, ContextCompat.getMainExecutor(ctx))
}
},
modifier = Modifier.fillMaxSize()
)
}

View file

@ -0,0 +1,48 @@
package com.payfrit.food.ui.screens.scan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.payfrit.food.PayfritFoodApp
import com.payfrit.food.data.model.Product
import com.payfrit.food.data.repository.ProductRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class ScanViewModel : ViewModel() {
private val repo = ProductRepository(PayfritFoodApp.instance.apiClient.api)
private val _scanState = MutableStateFlow<ScanState>(ScanState.Idle)
val scanState: StateFlow<ScanState> = _scanState.asStateFlow()
fun lookupBarcode(barcode: String) {
if (_scanState.value is ScanState.Loading) return
viewModelScope.launch {
_scanState.value = ScanState.Loading
repo.lookupByBarcode(barcode).fold(
onSuccess = { product ->
_scanState.value = ScanState.Found(product)
},
onFailure = { error ->
_scanState.value = ScanState.Error(
error.message ?: "Product not found"
)
}
)
}
}
fun reset() {
_scanState.value = ScanState.Idle
}
}
sealed class ScanState {
data object Idle : ScanState()
data object Loading : ScanState()
data class Found(val product: Product) : ScanState()
data class Error(val message: String) : ScanState()
}

View file

@ -0,0 +1,29 @@
package com.payfrit.food.ui.theme
import androidx.compose.ui.graphics.Color
// Brand colors
val PayfritGreen = Color(0xFF4CAF50)
val PayfritDarkGreen = Color(0xFF2E7D32)
val PayfritLightGreen = Color(0xFFC8E6C9)
// Score grade colors
val GradeExcellent = Color(0xFF2E7D32)
val GradeGood = Color(0xFF4CAF50)
val GradeFair = Color(0xFFFF9800)
val GradePoor = Color(0xFFF44336)
// NOVA colors
val Nova1 = Color(0xFF4CAF50)
val Nova2 = Color(0xFFFFC107)
val Nova3 = Color(0xFFFF9800)
val Nova4 = Color(0xFFF44336)
// Neutral
val SurfaceLight = Color(0xFFFAFAFA)
val SurfaceDark = Color(0xFF121212)
val OnSurfaceVariant = Color(0xFF757575)
// Sponsor highlight
val SponsoredGold = Color(0xFFFFF3E0)
val SponsoredBorder = Color(0xFFFFB74D)

View file

@ -0,0 +1,66 @@
package com.payfrit.food.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = PayfritGreen,
onPrimary = Color.White,
primaryContainer = PayfritLightGreen,
onPrimaryContainer = PayfritDarkGreen,
secondary = Color(0xFF546E7A),
onSecondary = Color.White,
background = SurfaceLight,
surface = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFF5F5F5),
onSurfaceVariant = OnSurfaceVariant,
outline = Color(0xFFBDBDBD),
)
private val DarkColorScheme = darkColorScheme(
primary = PayfritGreen,
onPrimary = Color.White,
primaryContainer = PayfritDarkGreen,
onPrimaryContainer = PayfritLightGreen,
secondary = Color(0xFF90A4AE),
onSecondary = Color.Black,
background = SurfaceDark,
surface = Color(0xFF1E1E1E),
onBackground = Color(0xFFE6E1E5),
onSurface = Color(0xFFE6E1E5),
surfaceVariant = Color(0xFF2C2C2C),
onSurfaceVariant = Color(0xFFBDBDBD),
outline = Color(0xFF616161),
)
@Composable
fun PayfritFoodTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = Color.Transparent.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
content = content,
)
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Payfrit Food</string>
<string name="tab_scan">Scan</string>
<string name="tab_favorites">Favorites</string>
<string name="tab_history">History</string>
<string name="tab_account">Account</string>
<string name="scan_prompt">Point your camera at a barcode</string>
<string name="manual_entry">Enter barcode manually</string>
<string name="no_product_found">Product not found</string>
<string name="loading">Loading…</string>
<string name="error_network">Network error. Check your connection.</string>
<string name="login">Log In</string>
<string name="register">Create Account</string>
<string name="logout">Log Out</string>
<string name="delete_account">Delete Account</string>
<string name="export_data">Export My Data</string>
<string name="alternatives_title">Healthier Alternatives</string>
<string name="sponsored">Sponsored</string>
<string name="score_label">Health Score</string>
<string name="nova_label">NOVA Group</string>
<string name="nutrition_label">Nutrition Facts</string>
<string name="ingredients_label">Ingredients</string>
<string name="dietary_label">Dietary Info</string>
<string name="add_favorite">Save to Favorites</string>
<string name="remove_favorite">Remove from Favorites</string>
<string name="camera_permission_required">Camera permission is required to scan barcodes</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PayfritFood" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>

5
build.gradle.kts Normal file
View file

@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22" apply false
}

4
gradle.properties Normal file
View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

17
settings.gradle.kts Normal file
View file

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolution {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "PayfritFood"
include(":app")