diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..c4f4645
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,117 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+ id 'kotlin-kapt'
+ id 'com.google.dagger.hilt.android'
+}
+
+android {
+ namespace 'com.pantree.app'
+ compileSdk 34
+
+ defaultConfig {
+ applicationId "com.pantree.app"
+ minSdk 26
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary true
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion '1.5.4'
+ }
+ 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.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'
+
+ // ViewModel + Lifecycle
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
+ implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0'
+
+ // Hilt DI
+ implementation 'com.google.dagger:hilt-android:2.50'
+ kapt 'com.google.dagger:hilt-android-compiler:2.50'
+ implementation 'androidx.hilt:hilt-navigation-compose:1.1.0'
+
+ // Room
+ implementation 'androidx.room:room-runtime:2.6.1'
+ implementation 'androidx.room:room-ktx:2.6.1'
+ kapt 'androidx.room:room-compiler:2.6.1'
+
+ // Retrofit + OkHttp
+ implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+ implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
+ implementation 'com.squareup.okhttp3:okhttp:4.12.0'
+ implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
+
+ // Security (EncryptedSharedPreferences)
+ implementation 'androidx.security:security-crypto:1.1.0-alpha06'
+
+ // Google Sign-In
+ implementation 'com.google.android.gms:play-services-auth:20.7.0'
+
+ // Coil (image loading)
+ implementation 'io.coil-kt:coil-compose:2.5.0'
+
+ // Coroutines
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
+
+ // DataStore
+ implementation 'androidx.datastore:datastore-preferences:1.0.0'
+
+ // Testing
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
+ testImplementation 'io.mockk:mockk:1.13.8'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+ androidTestImplementation platform('androidx.compose:compose-bom:2024.01.00')
+ androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
+}
+
+kapt {
+ correctErrorTypes true
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7cdfb7e
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/pantree/app/MainActivity.kt b/android/app/src/main/java/com/pantree/app/MainActivity.kt
new file mode 100644
index 0000000..66369bd
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/MainActivity.kt
@@ -0,0 +1,58 @@
+package com.pantree.app
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.pantree.app.ui.navigation.MainScaffold
+import com.pantree.app.ui.navigation.PantreeNavHost
+import com.pantree.app.ui.theme.PantreeTheme
+import com.pantree.app.util.ConnectivityObserver
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var connectivityObserver: ConnectivityObserver
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ PantreeTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ val isOffline by connectivityObserver.isOffline.collectAsStateWithLifecycle(
+ initialValue = false
+ )
+
+ // Auth state is determined by token validity at startup.
+ // The NavHost handles all subsequent auth transitions.
+ val tokenManager = (application as PantreeApplication)
+ .let {
+ // Accessed via Hilt injection in the NavHost's ViewModels
+ // isLoggedIn is checked once at startup for the start destination
+ false // placeholder — actual check is in AuthViewModel
+ }
+
+ PantreeNavHost(
+ isLoggedIn = false, // NavHost checks token via AuthViewModel on first composable
+ isOffline = isOffline
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/PantreeApplication.kt b/android/app/src/main/java/com/pantree/app/PantreeApplication.kt
new file mode 100644
index 0000000..8e54987
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/PantreeApplication.kt
@@ -0,0 +1,7 @@
+package com.pantree.app
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class PantreeApplication : Application()
diff --git a/android/app/src/main/java/com/pantree/app/data/local/PantreeDatabase.kt b/android/app/src/main/java/com/pantree/app/data/local/PantreeDatabase.kt
new file mode 100644
index 0000000..6c5a13b
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/local/PantreeDatabase.kt
@@ -0,0 +1,23 @@
+package com.pantree.app.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.pantree.app.data.local.dao.*
+import com.pantree.app.data.local.entity.*
+
+@Database(
+ entities = [
+ PantryItemEntity::class,
+ ShoppingListEntity::class,
+ ShoppingListItemEntity::class,
+ RecipeCacheEntity::class
+ ],
+ version = 1,
+ exportSchema = false
+)
+abstract class PantreeDatabase : RoomDatabase() {
+ abstract fun pantryDao(): PantryDao
+ abstract fun shoppingListDao(): ShoppingListDao
+ abstract fun shoppingListItemDao(): ShoppingListItemDao
+ abstract fun recipeCacheDao(): RecipeCacheDao
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/local/TokenManager.kt b/android/app/src/main/java/com/pantree/app/data/local/TokenManager.kt
new file mode 100644
index 0000000..429441d
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/local/TokenManager.kt
@@ -0,0 +1,86 @@
+package com.pantree.app.data.local
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Secure JWT storage using EncryptedSharedPreferences backed by Android Keystore.
+ * Raw token never written to unencrypted storage.
+ */
+@Singleton
+class TokenManager @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ companion object {
+ private const val PREFS_FILE = "pantree_secure_prefs"
+ private const val KEY_TOKEN = "auth_token"
+ private const val KEY_EXPIRES_AT = "token_expires_at"
+ private const val KEY_USER_ID = "user_id"
+ private const val KEY_USER_EMAIL = "user_email"
+ private const val KEY_USER_NAME = "user_name"
+ private const val KEY_PROFILE_PIC = "profile_picture_url"
+ private const val KEY_LAST_SYNC = "last_sync_timestamp"
+ }
+
+ private val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val prefs = EncryptedSharedPreferences.create(
+ context,
+ PREFS_FILE,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ fun saveToken(token: String, expiresAt: String) {
+ prefs.edit()
+ .putString(KEY_TOKEN, token)
+ .putString(KEY_EXPIRES_AT, expiresAt)
+ .apply()
+ }
+
+ fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
+
+ fun getExpiresAt(): String? = prefs.getString(KEY_EXPIRES_AT, null)
+
+ fun isTokenValid(): Boolean {
+ val token = getToken() ?: return false
+ val expiresAt = getExpiresAt() ?: return false
+ return try {
+ val expiry = java.time.Instant.parse(expiresAt)
+ expiry.isAfter(java.time.Instant.now())
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ fun saveUserInfo(userId: String, email: String, name: String, profilePicUrl: String?) {
+ prefs.edit()
+ .putString(KEY_USER_ID, userId)
+ .putString(KEY_USER_EMAIL, email)
+ .putString(KEY_USER_NAME, name)
+ .putString(KEY_PROFILE_PIC, profilePicUrl)
+ .apply()
+ }
+
+ fun getUserId(): String? = prefs.getString(KEY_USER_ID, null)
+ fun getUserEmail(): String? = prefs.getString(KEY_USER_EMAIL, null)
+ fun getUserName(): String? = prefs.getString(KEY_USER_NAME, null)
+ fun getProfilePicUrl(): String? = prefs.getString(KEY_PROFILE_PIC, null)
+
+ fun saveLastSyncTimestamp(timestamp: String) {
+ prefs.edit().putString(KEY_LAST_SYNC, timestamp).apply()
+ }
+
+ fun getLastSyncTimestamp(): String? = prefs.getString(KEY_LAST_SYNC, null)
+
+ fun clearAll() {
+ prefs.edit().clear().apply()
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/local/dao/Daos.kt b/android/app/src/main/java/com/pantree/app/data/local/dao/Daos.kt
new file mode 100644
index 0000000..b624454
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/local/dao/Daos.kt
@@ -0,0 +1,102 @@
+package com.pantree.app.data.local.dao
+
+import androidx.room.*
+import com.pantree.app.data.local.entity.*
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface PantryDao {
+
+ @Query("SELECT * FROM pantry_items ORDER BY item_name COLLATE NOCASE ASC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM pantry_items ORDER BY item_name COLLATE NOCASE ASC")
+ suspend fun getAll(): List
+
+ @Query("SELECT * FROM pantry_items WHERE id = :id")
+ suspend fun getById(id: String): PantryItemEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(items: List)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(item: PantryItemEntity)
+
+ @Update
+ suspend fun update(item: PantryItemEntity)
+
+ @Query("DELETE FROM pantry_items WHERE id = :id")
+ suspend fun deleteById(id: String)
+
+ @Query("DELETE FROM pantry_items")
+ suspend fun deleteAll()
+}
+
+@Dao
+interface ShoppingListDao {
+
+ @Query("SELECT * FROM shopping_lists ORDER BY last_modified DESC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM shopping_lists ORDER BY last_modified DESC")
+ suspend fun getAll(): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(lists: List)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(list: ShoppingListEntity)
+
+ @Query("DELETE FROM shopping_lists WHERE id = :id")
+ suspend fun deleteById(id: String)
+
+ @Query("DELETE FROM shopping_lists")
+ suspend fun deleteAll()
+}
+
+@Dao
+interface ShoppingListItemDao {
+
+ @Query("SELECT * FROM shopping_list_items WHERE shopping_list_id = :listId ORDER BY item_name COLLATE NOCASE ASC")
+ fun observeByListId(listId: String): Flow>
+
+ @Query("SELECT * FROM shopping_list_items WHERE shopping_list_id = :listId ORDER BY item_name COLLATE NOCASE ASC")
+ suspend fun getByListId(listId: String): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(items: List)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(item: ShoppingListItemEntity)
+
+ @Update
+ suspend fun update(item: ShoppingListItemEntity)
+
+ @Query("DELETE FROM shopping_list_items WHERE id = :id")
+ suspend fun deleteById(id: String)
+
+ @Query("DELETE FROM shopping_list_items WHERE shopping_list_id = :listId")
+ suspend fun deleteByListId(listId: String)
+
+ @Query("DELETE FROM shopping_list_items")
+ suspend fun deleteAll()
+}
+
+@Dao
+interface RecipeCacheDao {
+
+ @Query("SELECT * FROM recipes_cache ORDER BY name COLLATE NOCASE ASC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM recipes_cache ORDER BY name COLLATE NOCASE ASC")
+ suspend fun getAll(): List
+
+ @Query("SELECT * FROM recipes_cache WHERE id = :id")
+ suspend fun getById(id: String): RecipeCacheEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(recipes: List)
+
+ @Query("DELETE FROM recipes_cache")
+ suspend fun deleteAll()
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/local/entity/Entities.kt b/android/app/src/main/java/com/pantree/app/data/local/entity/Entities.kt
new file mode 100644
index 0000000..4b2ea0f
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/local/entity/Entities.kt
@@ -0,0 +1,47 @@
+package com.pantree.app.data.local.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "pantry_items")
+data class PantryItemEntity(
+ @PrimaryKey val id: String,
+ @ColumnInfo(name = "item_name") val itemName: String,
+ val quantity: Int,
+ @ColumnInfo(name = "last_modified") val lastModified: String,
+ @ColumnInfo(name = "created_at") val createdAt: String
+)
+
+@Entity(tableName = "shopping_lists")
+data class ShoppingListEntity(
+ @PrimaryKey val id: String,
+ @ColumnInfo(name = "list_name") val listName: String,
+ @ColumnInfo(name = "item_count") val itemCount: Int,
+ @ColumnInfo(name = "checked_count") val checkedCount: Int,
+ @ColumnInfo(name = "last_modified") val lastModified: String,
+ @ColumnInfo(name = "created_at") val createdAt: String
+)
+
+@Entity(tableName = "shopping_list_items")
+data class ShoppingListItemEntity(
+ @PrimaryKey val id: String,
+ @ColumnInfo(name = "shopping_list_id") val shoppingListId: String,
+ @ColumnInfo(name = "item_name") val itemName: String,
+ val quantity: Double,
+ val unit: String,
+ @ColumnInfo(name = "checked_off") val checkedOff: Boolean,
+ @ColumnInfo(name = "last_modified") val lastModified: String
+)
+
+@Entity(tableName = "recipes_cache")
+data class RecipeCacheEntity(
+ @PrimaryKey val id: String,
+ val name: String,
+ val servings: Int,
+ @ColumnInfo(name = "ingredient_count") val ingredientCount: Int,
+ @ColumnInfo(name = "availability_status") val availabilityStatus: String,
+ @ColumnInfo(name = "available_count") val availableCount: Int,
+ @ColumnInfo(name = "total_count") val totalCount: Int,
+ @ColumnInfo(name = "missing_ingredients_json") val missingIngredientsJson: String
+)
diff --git a/android/app/src/main/java/com/pantree/app/data/model/ApiModels.kt b/android/app/src/main/java/com/pantree/app/data/model/ApiModels.kt
new file mode 100644
index 0000000..052b508
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/model/ApiModels.kt
@@ -0,0 +1,264 @@
+package com.pantree.app.data.model
+
+import com.google.gson.annotations.SerializedName
+
+// ─── Auth ────────────────────────────────────────────────────────────────────
+
+data class SignupRequest(
+ val email: String,
+ val password: String,
+ val name: String
+)
+
+data class SigninRequest(
+ val email: String,
+ val password: String
+)
+
+data class GoogleAuthRequest(
+ @SerializedName("id_token") val idToken: String
+)
+
+data class PasswordResetRequest(
+ val email: String
+)
+
+data class PasswordResetConfirmRequest(
+ val token: String,
+ @SerializedName("new_password") val newPassword: String
+)
+
+data class AuthResponse(
+ val user: UserDto,
+ val token: String,
+ @SerializedName("expires_at") val expiresAt: String
+)
+
+data class UserDto(
+ val id: String,
+ val email: String,
+ val name: String,
+ @SerializedName("profile_picture_url") val profilePictureUrl: String?,
+ @SerializedName("email_verified") val emailVerified: Boolean,
+ @SerializedName("deleted_at") val deletedAt: String?,
+ @SerializedName("created_at") val createdAt: String
+)
+
+data class RestoreAccountResponse(
+ val user: UserDto,
+ val message: String,
+ val timestamp: String
+)
+
+data class MessageResponse(
+ val message: String,
+ val timestamp: String
+)
+
+// ─── Pantry ──────────────────────────────────────────────────────────────────
+
+data class PantryItemDto(
+ val id: String,
+ @SerializedName("item_name") val itemName: String,
+ val quantity: Int,
+ @SerializedName("last_modified") val lastModified: String,
+ @SerializedName("created_at") val createdAt: String
+)
+
+data class PantryListResponse(
+ val items: List,
+ @SerializedName("synced_at") val syncedAt: String
+)
+
+data class PantryItemResponse(
+ val item: PantryItemDto
+)
+
+data class AddPantryItemRequest(
+ @SerializedName("item_name") val itemName: String,
+ val quantity: Int
+)
+
+data class UpdatePantryItemRequest(
+ val quantity: Int,
+ @SerializedName("last_modified") val lastModified: String
+)
+
+// ─── Recipes ─────────────────────────────────────────────────────────────────
+
+data class RecipeSummaryDto(
+ val id: String,
+ val name: String,
+ val servings: Int,
+ @SerializedName("ingredient_count") val ingredientCount: Int,
+ val availability: AvailabilityDto
+)
+
+data class AvailabilityDto(
+ val status: String, // "can_make" | "partial" | "missing"
+ @SerializedName("available_count") val availableCount: Int,
+ @SerializedName("total_count") val totalCount: Int,
+ @SerializedName("missing_ingredients") val missingIngredients: List
+)
+
+data class RecipeListResponse(
+ val recipes: List,
+ @SerializedName("synced_at") val syncedAt: String
+)
+
+data class RecipeDetailDto(
+ val id: String,
+ val name: String,
+ val servings: Int,
+ @SerializedName("scaled_servings") val scaledServings: Int,
+ val instructions: String,
+ val ingredients: List,
+ val availability: AvailabilitySummaryDto
+)
+
+data class RecipeIngredientDto(
+ val id: String,
+ @SerializedName("item_name") val itemName: String,
+ val quantity: Double,
+ val unit: String,
+ @SerializedName("in_pantry") val inPantry: Boolean
+)
+
+data class AvailabilitySummaryDto(
+ val status: String,
+ @SerializedName("available_count") val availableCount: Int,
+ @SerializedName("total_count") val totalCount: Int
+)
+
+data class RecipeDetailResponse(
+ val recipe: RecipeDetailDto
+)
+
+// ─── Shopping Lists ───────────────────────────────────────────────────────────
+
+data class ShoppingListSummaryDto(
+ val id: String,
+ @SerializedName("list_name") val listName: String,
+ @SerializedName("item_count") val itemCount: Int,
+ @SerializedName("checked_count") val checkedCount: Int,
+ @SerializedName("last_modified") val lastModified: String,
+ @SerializedName("created_at") val createdAt: String
+)
+
+data class ShoppingListsResponse(
+ @SerializedName("shopping_lists") val shoppingLists: List,
+ @SerializedName("synced_at") val syncedAt: String
+)
+
+data class ShoppingListDetailDto(
+ val id: String,
+ @SerializedName("list_name") val listName: String,
+ @SerializedName("last_modified") val lastModified: String,
+ @SerializedName("created_at") val createdAt: String,
+ val items: List
+)
+
+data class ShoppingListItemDto(
+ val id: String,
+ @SerializedName("item_name") val itemName: String,
+ val quantity: Double,
+ val unit: String,
+ @SerializedName("checked_off") val checkedOff: Boolean,
+ @SerializedName("last_modified") val lastModified: String
+)
+
+data class ShoppingListDetailResponse(
+ @SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto,
+ @SerializedName("synced_at") val syncedAt: String
+)
+
+data class CreateShoppingListRequest(
+ @SerializedName("list_name") val listName: String
+)
+
+data class CreateShoppingListResponse(
+ @SerializedName("shopping_list") val shoppingList: ShoppingListSummaryDto
+)
+
+data class AddShoppingItemRequest(
+ @SerializedName("item_name") val itemName: String,
+ val quantity: Double,
+ val unit: String
+)
+
+data class AddShoppingItemResponse(
+ val item: ShoppingListItemDto,
+ val merged: Boolean,
+ @SerializedName("previous_quantity") val previousQuantity: Double?
+)
+
+data class AddRecipesToListRequest(
+ @SerializedName("recipe_ids") val recipeIds: List,
+ val scale: Int = 1
+)
+
+data class AddRecipesToListResponse(
+ @SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto,
+ @SerializedName("recipes_added") val recipesAdded: Int,
+ @SerializedName("items_merged") val itemsMerged: Int,
+ @SerializedName("items_created") val itemsCreated: Int
+)
+
+data class UpdateShoppingItemRequest(
+ val quantity: Double? = null,
+ val unit: String? = null,
+ @SerializedName("checked_off") val checkedOff: Boolean? = null
+)
+
+data class UpdateShoppingItemResponse(
+ val item: ShoppingListItemDto
+)
+
+// ─── Sync ─────────────────────────────────────────────────────────────────────
+
+data class SyncResponse(
+ val pantry: SyncPantryDto,
+ @SerializedName("shopping_lists") val shoppingLists: List,
+ @SerializedName("server_timestamp") val serverTimestamp: String,
+ @SerializedName("full_sync") val fullSync: Boolean
+)
+
+data class SyncPantryDto(
+ val items: List
+)
+
+data class SyncPantryItemDto(
+ val id: String,
+ @SerializedName("item_name") val itemName: String,
+ val quantity: Int,
+ @SerializedName("last_modified") val lastModified: String,
+ val deleted: Boolean
+)
+
+data class SyncShoppingListDto(
+ val id: String,
+ @SerializedName("list_name") val listName: String,
+ @SerializedName("last_modified") val lastModified: String,
+ val deleted: Boolean,
+ val items: List
+)
+
+data class SyncShoppingItemDto(
+ val id: String,
+ @SerializedName("item_name") val itemName: String,
+ val quantity: Double,
+ val unit: String,
+ @SerializedName("checked_off") val checkedOff: Boolean,
+ @SerializedName("last_modified") val lastModified: String,
+ val deleted: Boolean
+)
+
+// ─── Error ────────────────────────────────────────────────────────────────────
+
+data class ApiError(
+ val error: String,
+ val code: String,
+ val timestamp: String,
+ @SerializedName("deletion_scheduled_at") val deletionScheduledAt: String? = null,
+ @SerializedName("can_restore") val canRestore: Boolean? = null
+)
diff --git a/android/app/src/main/java/com/pantree/app/data/remote/AuthInterceptor.kt b/android/app/src/main/java/com/pantree/app/data/remote/AuthInterceptor.kt
new file mode 100644
index 0000000..0e95096
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/remote/AuthInterceptor.kt
@@ -0,0 +1,28 @@
+package com.pantree.app.data.remote
+
+import com.pantree.app.data.local.TokenManager
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+
+/**
+ * OkHttp interceptor that attaches the JWT Bearer token to every outgoing request.
+ * Auth endpoints (signup, signin, google, password-reset) don't need a token,
+ * but sending one on those routes is harmless — the server ignores it.
+ */
+class AuthInterceptor @Inject constructor(
+ private val tokenManager: TokenManager
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val token = tokenManager.getToken()
+ val request = if (token != null) {
+ chain.request().newBuilder()
+ .addHeader("Authorization", "Bearer $token")
+ .build()
+ } else {
+ chain.request()
+ }
+ return chain.proceed(request)
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/remote/NetworkResult.kt b/android/app/src/main/java/com/pantree/app/data/remote/NetworkResult.kt
new file mode 100644
index 0000000..4e921b5
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/remote/NetworkResult.kt
@@ -0,0 +1,71 @@
+package com.pantree.app.data.remote
+
+import com.google.gson.Gson
+import com.pantree.app.data.model.ApiError
+import retrofit2.Response
+
+/**
+ * Sealed wrapper for every API call result.
+ * Every screen gets exactly one of these — no raw exceptions leaking into the UI.
+ */
+sealed class NetworkResult {
+ data class Success(val data: T) : NetworkResult()
+ data class Error(
+ val code: String,
+ val message: String,
+ val httpStatus: Int,
+ val extra: ApiError? = null
+ ) : NetworkResult()
+ object Loading : NetworkResult()
+}
+
+/**
+ * Executes a Retrofit suspend call and wraps the result in NetworkResult.
+ * Parses the error body into ApiError when available.
+ */
+suspend fun safeApiCall(call: suspend () -> Response): NetworkResult {
+ return try {
+ val response = call()
+ if (response.isSuccessful) {
+ val body = response.body()
+ if (body != null) {
+ NetworkResult.Success(body)
+ } else {
+ // 204 No Content — success with no body
+ @Suppress("UNCHECKED_CAST")
+ NetworkResult.Success(Unit as T)
+ }
+ } else {
+ val errorBody = response.errorBody()?.string()
+ val apiError = try {
+ Gson().fromJson(errorBody, ApiError::class.java)
+ } catch (e: Exception) {
+ null
+ }
+ NetworkResult.Error(
+ code = apiError?.code ?: "UNKNOWN_ERROR",
+ message = apiError?.error ?: "Something went wrong. Please try again.",
+ httpStatus = response.code(),
+ extra = apiError
+ )
+ }
+ } catch (e: java.net.UnknownHostException) {
+ NetworkResult.Error(
+ code = "NO_CONNECTION",
+ message = "No internet connection. Your data is shown from cache.",
+ httpStatus = 0
+ )
+ } catch (e: java.net.SocketTimeoutException) {
+ NetworkResult.Error(
+ code = "TIMEOUT",
+ message = "The request timed out. Please check your connection and try again.",
+ httpStatus = 0
+ )
+ } catch (e: Exception) {
+ NetworkResult.Error(
+ code = "INTERNAL_ERROR",
+ message = "Something unexpected happened. Please try again.",
+ httpStatus = 0
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/remote/PantreeApiService.kt b/android/app/src/main/java/com/pantree/app/data/remote/PantreeApiService.kt
new file mode 100644
index 0000000..725b1f4
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/remote/PantreeApiService.kt
@@ -0,0 +1,106 @@
+package com.pantree.app.data.remote
+
+import com.pantree.app.data.model.*
+import retrofit2.Response
+import retrofit2.http.*
+
+interface PantreeApiService {
+
+ // ─── Auth ────────────────────────────────────────────────────────────────
+
+ @POST("v1/auth/signup")
+ suspend fun signup(@Body request: SignupRequest): Response
+
+ @POST("v1/auth/signin")
+ suspend fun signin(@Body request: SigninRequest): Response
+
+ @POST("v1/auth/google")
+ suspend fun googleAuth(@Body request: GoogleAuthRequest): Response
+
+ @POST("v1/auth/password-reset")
+ suspend fun requestPasswordReset(@Body request: PasswordResetRequest): Response
+
+ @PUT("v1/auth/password-reset")
+ suspend fun confirmPasswordReset(@Body request: PasswordResetConfirmRequest): Response
+
+ @DELETE("v1/auth/account")
+ suspend fun deleteAccount(): Response
+
+ @POST("v1/auth/restore-account")
+ suspend fun restoreAccount(): Response
+
+ // ─── Pantry ──────────────────────────────────────────────────────────────
+
+ @GET("v1/pantry")
+ suspend fun getPantryItems(): Response
+
+ @POST("v1/pantry")
+ suspend fun addPantryItem(@Body request: AddPantryItemRequest): Response
+
+ @PUT("v1/pantry/{item_id}")
+ suspend fun updatePantryItem(
+ @Path("item_id") itemId: String,
+ @Body request: UpdatePantryItemRequest
+ ): Response
+
+ @DELETE("v1/pantry/{item_id}")
+ suspend fun deletePantryItem(@Path("item_id") itemId: String): Response
+
+ // ─── Recipes ─────────────────────────────────────────────────────────────
+
+ @GET("v1/recipes")
+ suspend fun getRecipes(
+ @Query("filter") filter: String? = null,
+ @Query("scale") scale: Int? = null
+ ): Response
+
+ @GET("v1/recipes/{recipe_id}")
+ suspend fun getRecipeDetail(
+ @Path("recipe_id") recipeId: String,
+ @Query("scale") scale: Int? = null
+ ): Response
+
+ // ─── Shopping Lists ───────────────────────────────────────────────────────
+
+ @GET("v1/shopping-lists")
+ suspend fun getShoppingLists(): Response
+
+ @POST("v1/shopping-lists")
+ suspend fun createShoppingList(@Body request: CreateShoppingListRequest): Response
+
+ @GET("v1/shopping-lists/{list_id}")
+ suspend fun getShoppingListDetail(@Path("list_id") listId: String): Response
+
+ @DELETE("v1/shopping-lists/{list_id}")
+ suspend fun deleteShoppingList(@Path("list_id") listId: String): Response
+
+ @POST("v1/shopping-lists/{list_id}/items")
+ suspend fun addShoppingItem(
+ @Path("list_id") listId: String,
+ @Body request: AddShoppingItemRequest
+ ): Response
+
+ @POST("v1/shopping-lists/{list_id}/add-recipes")
+ suspend fun addRecipesToList(
+ @Path("list_id") listId: String,
+ @Body request: AddRecipesToListRequest
+ ): Response
+
+ @PUT("v1/shopping-lists/{list_id}/items/{item_id}")
+ suspend fun updateShoppingItem(
+ @Path("list_id") listId: String,
+ @Path("item_id") itemId: String,
+ @Body request: UpdateShoppingItemRequest
+ ): Response
+
+ @DELETE("v1/shopping-lists/{list_id}/items/{item_id}")
+ suspend fun deleteShoppingItem(
+ @Path("list_id") listId: String,
+ @Path("item_id") itemId: String
+ ): Response
+
+ // ─── Sync ─────────────────────────────────────────────────────────────────
+
+ @GET("v1/sync")
+ suspend fun sync(@Query("since") since: String? = null): Response
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/repository/AuthRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/AuthRepository.kt
new file mode 100644
index 0000000..e5d5090
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/repository/AuthRepository.kt
@@ -0,0 +1,71 @@
+package com.pantree.app.data.repository
+
+import com.pantree.app.data.local.TokenManager
+import com.pantree.app.data.model.*
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.remote.PantreeApiService
+import com.pantree.app.data.remote.safeApiCall
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthRepository @Inject constructor(
+ private val api: PantreeApiService,
+ private val tokenManager: TokenManager
+) {
+
+ suspend fun signup(email: String, password: String, name: String): NetworkResult {
+ val result = safeApiCall { api.signup(SignupRequest(email, password, name)) }
+ if (result is NetworkResult.Success) {
+ persistSession(result.data)
+ }
+ return result
+ }
+
+ suspend fun signin(email: String, password: String): NetworkResult {
+ val result = safeApiCall { api.signin(SigninRequest(email, password)) }
+ if (result is NetworkResult.Success) {
+ persistSession(result.data)
+ }
+ return result
+ }
+
+ suspend fun googleAuth(idToken: String): NetworkResult {
+ val result = safeApiCall { api.googleAuth(GoogleAuthRequest(idToken)) }
+ if (result is NetworkResult.Success) {
+ persistSession(result.data)
+ }
+ return result
+ }
+
+ suspend fun requestPasswordReset(email: String): NetworkResult =
+ safeApiCall { api.requestPasswordReset(PasswordResetRequest(email)) }
+
+ suspend fun confirmPasswordReset(token: String, newPassword: String): NetworkResult =
+ safeApiCall { api.confirmPasswordReset(PasswordResetConfirmRequest(token, newPassword)) }
+
+ suspend fun deleteAccount(): NetworkResult {
+ val result = safeApiCall { api.deleteAccount() }
+ if (result is NetworkResult.Success) {
+ tokenManager.clearAll()
+ }
+ return result
+ }
+
+ suspend fun restoreAccount(): NetworkResult =
+ safeApiCall { api.restoreAccount() }
+
+ fun isLoggedIn(): Boolean = tokenManager.isTokenValid()
+
+ fun signOut() = tokenManager.clearAll()
+
+ private fun persistSession(auth: AuthResponse) {
+ tokenManager.saveToken(auth.token, auth.expiresAt)
+ tokenManager.saveUserInfo(
+ userId = auth.user.id,
+ email = auth.user.email,
+ name = auth.user.name,
+ profilePicUrl = auth.user.profilePictureUrl
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/repository/PantryRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/PantryRepository.kt
new file mode 100644
index 0000000..cb91ca9
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/repository/PantryRepository.kt
@@ -0,0 +1,72 @@
+package com.pantree.app.data.repository
+
+import com.pantree.app.data.local.dao.PantryDao
+import com.pantree.app.data.local.entity.PantryItemEntity
+import com.pantree.app.data.model.*
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.remote.PantreeApiService
+import com.pantree.app.data.remote.safeApiCall
+import kotlinx.coroutines.flow.Flow
+import java.time.Instant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PantryRepository @Inject constructor(
+ private val api: PantreeApiService,
+ private val pantryDao: PantryDao
+) {
+
+ /** Live stream of pantry items from local cache. Always available, even offline. */
+ fun observePantryItems(): Flow> = pantryDao.observeAll()
+
+ /** Fetch from server and refresh local cache. Returns error on failure. */
+ suspend fun refreshPantry(): NetworkResult {
+ val result = safeApiCall { api.getPantryItems() }
+ if (result is NetworkResult.Success) {
+ val entities = result.data.items.map { it.toEntity() }
+ pantryDao.deleteAll()
+ pantryDao.insertAll(entities)
+ }
+ return result
+ }
+
+ suspend fun addItem(itemName: String, quantity: Int): NetworkResult {
+ val result = safeApiCall {
+ api.addPantryItem(AddPantryItemRequest(itemName, quantity))
+ }
+ if (result is NetworkResult.Success) {
+ pantryDao.insert(result.data.item.toEntity())
+ }
+ return result
+ }
+
+ suspend fun updateItem(itemId: String, quantity: Int): NetworkResult {
+ val result = safeApiCall {
+ api.updatePantryItem(
+ itemId,
+ UpdatePantryItemRequest(quantity, Instant.now().toString())
+ )
+ }
+ if (result is NetworkResult.Success) {
+ pantryDao.insert(result.data.item.toEntity())
+ }
+ return result
+ }
+
+ suspend fun deleteItem(itemId: String): NetworkResult {
+ val result = safeApiCall { api.deletePantryItem(itemId) }
+ if (result is NetworkResult.Success) {
+ pantryDao.deleteById(itemId)
+ }
+ return result
+ }
+
+ private fun PantryItemDto.toEntity() = PantryItemEntity(
+ id = id,
+ itemName = itemName,
+ quantity = quantity,
+ lastModified = lastModified,
+ createdAt = createdAt
+ )
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/repository/RecipeRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/RecipeRepository.kt
new file mode 100644
index 0000000..4943ce3
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/repository/RecipeRepository.kt
@@ -0,0 +1,47 @@
+package com.pantree.app.data.repository
+
+import com.pantree.app.data.local.dao.RecipeCacheDao
+import com.pantree.app.data.local.entity.RecipeCacheEntity
+import com.pantree.app.data.model.*
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.remote.PantreeApiService
+import com.pantree.app.data.remote.safeApiCall
+import com.google.gson.Gson
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class RecipeRepository @Inject constructor(
+ private val api: PantreeApiService,
+ private val recipeCacheDao: RecipeCacheDao
+) {
+ private val gson = Gson()
+
+ fun observeRecipes(): Flow> = recipeCacheDao.observeAll()
+
+ suspend fun refreshRecipes(filter: String? = null): NetworkResult {
+ val result = safeApiCall { api.getRecipes(filter = filter) }
+ if (result is NetworkResult.Success && filter == null) {
+ // Only replace full cache on unfiltered fetch
+ val entities = result.data.recipes.map { it.toEntity() }
+ recipeCacheDao.deleteAll()
+ recipeCacheDao.insertAll(entities)
+ }
+ return result
+ }
+
+ suspend fun getRecipeDetail(recipeId: String, scale: Int? = null): NetworkResult =
+ safeApiCall { api.getRecipeDetail(recipeId, scale) }
+
+ private fun RecipeSummaryDto.toEntity() = RecipeCacheEntity(
+ id = id,
+ name = name,
+ servings = servings,
+ ingredientCount = ingredientCount,
+ availabilityStatus = availability.status,
+ availableCount = availability.availableCount,
+ totalCount = availability.totalCount,
+ missingIngredientsJson = gson.toJson(availability.missingIngredients)
+ )
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/repository/ShoppingRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/ShoppingRepository.kt
new file mode 100644
index 0000000..04364c1
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/repository/ShoppingRepository.kt
@@ -0,0 +1,139 @@
+package com.pantree.app.data.repository
+
+import com.pantree.app.data.local.dao.ShoppingListDao
+import com.pantree.app.data.local.dao.ShoppingListItemDao
+import com.pantree.app.data.local.entity.ShoppingListEntity
+import com.pantree.app.data.local.entity.ShoppingListItemEntity
+import com.pantree.app.data.model.*
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.remote.PantreeApiService
+import com.pantree.app.data.remote.safeApiCall
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ShoppingRepository @Inject constructor(
+ private val api: PantreeApiService,
+ private val shoppingListDao: ShoppingListDao,
+ private val shoppingListItemDao: ShoppingListItemDao
+) {
+
+ fun observeShoppingLists(): Flow> = shoppingListDao.observeAll()
+
+ fun observeListItems(listId: String): Flow> =
+ shoppingListItemDao.observeByListId(listId)
+
+ suspend fun refreshShoppingLists(): NetworkResult {
+ val result = safeApiCall { api.getShoppingLists() }
+ if (result is NetworkResult.Success) {
+ val entities = result.data.shoppingLists.map { it.toEntity() }
+ shoppingListDao.deleteAll()
+ shoppingListDao.insertAll(entities)
+ }
+ return result
+ }
+
+ suspend fun getListDetail(listId: String): NetworkResult {
+ val result = safeApiCall { api.getShoppingListDetail(listId) }
+ if (result is NetworkResult.Success) {
+ val items = result.data.shoppingList.items.map { it.toEntity(listId) }
+ shoppingListItemDao.deleteByListId(listId)
+ shoppingListItemDao.insertAll(items)
+ }
+ return result
+ }
+
+ suspend fun createList(listName: String): NetworkResult {
+ val result = safeApiCall { api.createShoppingList(CreateShoppingListRequest(listName)) }
+ if (result is NetworkResult.Success) {
+ shoppingListDao.insert(result.data.shoppingList.toEntity())
+ }
+ return result
+ }
+
+ suspend fun deleteList(listId: String): NetworkResult {
+ val result = safeApiCall { api.deleteShoppingList(listId) }
+ if (result is NetworkResult.Success) {
+ shoppingListDao.deleteById(listId)
+ shoppingListItemDao.deleteByListId(listId)
+ }
+ return result
+ }
+
+ suspend fun addItem(
+ listId: String,
+ itemName: String,
+ quantity: Double,
+ unit: String
+ ): NetworkResult {
+ val result = safeApiCall {
+ api.addShoppingItem(listId, AddShoppingItemRequest(itemName, quantity, unit))
+ }
+ if (result is NetworkResult.Success) {
+ shoppingListItemDao.insert(result.data.item.toEntity(listId))
+ }
+ return result
+ }
+
+ suspend fun addRecipesToList(
+ listId: String,
+ recipeIds: List,
+ scale: Int = 1
+ ): NetworkResult {
+ val result = safeApiCall {
+ api.addRecipesToList(listId, AddRecipesToListRequest(recipeIds, scale))
+ }
+ if (result is NetworkResult.Success) {
+ val items = result.data.shoppingList.items.map { it.toEntity(listId) }
+ shoppingListItemDao.deleteByListId(listId)
+ shoppingListItemDao.insertAll(items)
+ }
+ return result
+ }
+
+ suspend fun updateItem(
+ listId: String,
+ itemId: String,
+ quantity: Double? = null,
+ unit: String? = null,
+ checkedOff: Boolean? = null
+ ): NetworkResult {
+ val result = safeApiCall {
+ api.updateShoppingItem(listId, itemId, UpdateShoppingItemRequest(quantity, unit, checkedOff))
+ }
+ if (result is NetworkResult.Success) {
+ shoppingListItemDao.insert(result.data.item.toEntity(listId))
+ }
+ return result
+ }
+
+ suspend fun deleteItem(listId: String, itemId: String): NetworkResult {
+ val result = safeApiCall { api.deleteShoppingItem(listId, itemId) }
+ if (result is NetworkResult.Success) {
+ shoppingListItemDao.deleteById(itemId)
+ }
+ return result
+ }
+
+ // ─── Mappers ─────────────────────────────────────────────────────────────
+
+ private fun ShoppingListSummaryDto.toEntity() = ShoppingListEntity(
+ id = id,
+ listName = listName,
+ itemCount = itemCount,
+ checkedCount = checkedCount,
+ lastModified = lastModified,
+ createdAt = createdAt
+ )
+
+ private fun ShoppingListItemDto.toEntity(listId: String) = ShoppingListItemEntity(
+ id = id,
+ shoppingListId = listId,
+ itemName = itemName,
+ quantity = quantity,
+ unit = unit,
+ checkedOff = checkedOff,
+ lastModified = lastModified
+ )
+}
diff --git a/android/app/src/main/java/com/pantree/app/data/repository/SyncRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/SyncRepository.kt
new file mode 100644
index 0000000..4db9632
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/data/repository/SyncRepository.kt
@@ -0,0 +1,101 @@
+package com.pantree.app.data.repository
+
+import com.pantree.app.data.local.TokenManager
+import com.pantree.app.data.local.dao.PantryDao
+import com.pantree.app.data.local.dao.ShoppingListDao
+import com.pantree.app.data.local.dao.ShoppingListItemDao
+import com.pantree.app.data.local.entity.PantryItemEntity
+import com.pantree.app.data.local.entity.ShoppingListEntity
+import com.pantree.app.data.local.entity.ShoppingListItemEntity
+import com.pantree.app.data.model.SyncResponse
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.remote.PantreeApiService
+import com.pantree.app.data.remote.safeApiCall
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SyncRepository @Inject constructor(
+ private val api: PantreeApiService,
+ private val tokenManager: TokenManager,
+ private val pantryDao: PantryDao,
+ private val shoppingListDao: ShoppingListDao,
+ private val shoppingListItemDao: ShoppingListItemDao
+) {
+
+ /**
+ * Performs a full or delta sync depending on whether a last-sync timestamp exists.
+ * On full sync: replaces all local data.
+ * On delta sync: applies only changed/deleted records.
+ */
+ suspend fun sync(): NetworkResult {
+ val since = tokenManager.getLastSyncTimestamp()
+ val result = safeApiCall { api.sync(since) }
+
+ if (result is NetworkResult.Success) {
+ val data = result.data
+
+ if (data.fullSync) {
+ // Full sync — replace everything
+ pantryDao.deleteAll()
+ shoppingListDao.deleteAll()
+ shoppingListItemDao.deleteAll()
+ }
+
+ // Apply pantry changes
+ val toInsert = data.pantry.items.filter { !it.deleted }
+ val toDelete = data.pantry.items.filter { it.deleted }
+
+ pantryDao.insertAll(toInsert.map {
+ PantryItemEntity(
+ id = it.id,
+ itemName = it.itemName,
+ quantity = it.quantity,
+ lastModified = it.lastModified,
+ createdAt = it.lastModified // fallback for delta sync
+ )
+ })
+ toDelete.forEach { pantryDao.deleteById(it.id) }
+
+ // Apply shopping list changes
+ for (list in data.shoppingLists) {
+ if (list.deleted) {
+ shoppingListDao.deleteById(list.id)
+ shoppingListItemDao.deleteByListId(list.id)
+ } else {
+ val itemCount = list.items.count { !it.deleted }
+ val checkedCount = list.items.count { !it.deleted && it.checkedOff }
+ shoppingListDao.insert(
+ ShoppingListEntity(
+ id = list.id,
+ listName = list.listName,
+ itemCount = itemCount,
+ checkedCount = checkedCount,
+ lastModified = list.lastModified,
+ createdAt = list.lastModified
+ )
+ )
+ val itemsToInsert = list.items.filter { !it.deleted }
+ val itemsToDelete = list.items.filter { it.deleted }
+
+ shoppingListItemDao.insertAll(itemsToInsert.map {
+ ShoppingListItemEntity(
+ id = it.id,
+ shoppingListId = list.id,
+ itemName = it.itemName,
+ quantity = it.quantity,
+ unit = it.unit,
+ checkedOff = it.checkedOff,
+ lastModified = it.lastModified
+ )
+ })
+ itemsToDelete.forEach { shoppingListItemDao.deleteById(it.id) }
+ }
+ }
+
+ tokenManager.saveLastSyncTimestamp(data.serverTimestamp)
+ }
+
+ return result
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/di/AppModule.kt b/android/app/src/main/java/com/pantree/app/di/AppModule.kt
new file mode 100644
index 0000000..350a6fc
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/di/AppModule.kt
@@ -0,0 +1,79 @@
+package com.pantree.app.di
+
+import android.content.Context
+import androidx.room.Room
+import com.pantree.app.data.local.PantreeDatabase
+import com.pantree.app.data.local.TokenManager
+import com.pantree.app.data.local.dao.*
+import com.pantree.app.data.remote.AuthInterceptor
+import com.pantree.app.data.remote.PantreeApiService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ // ─── Network ─────────────────────────────────────────────────────────────
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
+ val logging = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+ return OkHttpClient.Builder()
+ .addInterceptor(authInterceptor)
+ .addInterceptor(logging)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
+ Retrofit.Builder()
+ .baseUrl("https://api.pantree.app/")
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+
+ @Provides
+ @Singleton
+ fun providePantreeApiService(retrofit: Retrofit): PantreeApiService =
+ retrofit.create(PantreeApiService::class.java)
+
+ // ─── Database ─────────────────────────────────────────────────────────────
+
+ @Provides
+ @Singleton
+ fun providePantreeDatabase(@ApplicationContext context: Context): PantreeDatabase =
+ Room.databaseBuilder(
+ context,
+ PantreeDatabase::class.java,
+ "pantree.db"
+ ).fallbackToDestructiveMigration().build()
+
+ @Provides
+ fun providePantryDao(db: PantreeDatabase): PantryDao = db.pantryDao()
+
+ @Provides
+ fun provideShoppingListDao(db: PantreeDatabase): ShoppingListDao = db.shoppingListDao()
+
+ @Provides
+ fun provideShoppingListItemDao(db: PantreeDatabase): ShoppingListItemDao = db.shoppingListItemDao()
+
+ @Provides
+ fun provideRecipeCacheDao(db: PantreeDatabase): RecipeCacheDao = db.recipeCacheDao()
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/auth/AuthScreens.kt b/android/app/src/main/java/com/pantree/app/ui/auth/AuthScreens.kt
new file mode 100644
index 0000000..fb8cab9
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/auth/AuthScreens.kt
@@ -0,0 +1,699 @@
+package com.pantree.app.ui.auth
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+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.focus.FocusDirection
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.input.*
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SIGN IN SCREEN
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun SignInScreen(
+ uiState: AuthUiState,
+ onSignIn: (email: String, password: String) -> Unit,
+ onNavigateToSignUp: () -> Unit,
+ onNavigateToForgotPassword: () -> Unit,
+ onGoogleSignIn: () -> Unit,
+ onClearError: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val focusManager = LocalFocusManager.current
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ // Field-level validation
+ var emailError by remember { mutableStateOf(null) }
+ var passwordError by remember { mutableStateOf(null) }
+
+ fun validate(): Boolean {
+ var valid = true
+ emailError = if (email.isBlank()) {
+ valid = false; "Email is required"
+ } else if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
+ valid = false; "Enter a valid email address"
+ } else null
+
+ passwordError = if (password.isBlank()) {
+ valid = false; "Password is required"
+ } else null
+
+ return valid
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(64.dp))
+
+ // Logo / wordmark
+ Text(
+ text = "Pantree",
+ style = MaterialTheme.typography.displayMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "Your kitchen, organised.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ // Error banner
+ AnimatedVisibility(visible = uiState.errorMessage != null) {
+ uiState.errorMessage?.let { msg ->
+ ErrorBanner(message = msg, onDismiss = onClearError)
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ // Email field
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it; emailError = null; onClearError() },
+ label = { Text("Email") },
+ leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
+ isError = emailError != null,
+ supportingText = emailError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) }
+ ),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Password field
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it; passwordError = null; onClearError() },
+ label = { Text("Password") },
+ leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ )
+ }
+ },
+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ isError = passwordError != null,
+ supportingText = passwordError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ if (validate()) onSignIn(email.trim(), password)
+ }
+ ),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ // Forgot password
+ Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
+ TextButton(onClick = onNavigateToForgotPassword) {
+ Text("Forgot password?")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Sign in button
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ if (validate()) onSignIn(email.trim(), password)
+ },
+ enabled = !uiState.isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Sign in", style = MaterialTheme.typography.titleMedium)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Divider
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ Text(
+ text = " or ",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Google sign-in
+ OutlinedButton(
+ onClick = onGoogleSignIn,
+ enabled = !uiState.isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.AccountCircle,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Continue with Google", style = MaterialTheme.typography.titleMedium)
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Sign up link
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "Don't have an account?",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ TextButton(onClick = onNavigateToSignUp) {
+ Text("Sign up")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SIGN UP SCREEN
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun SignUpScreen(
+ uiState: AuthUiState,
+ onSignUp: (email: String, password: String, name: String) -> Unit,
+ onNavigateToSignIn: () -> Unit,
+ onGoogleSignIn: () -> Unit,
+ onClearError: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val focusManager = LocalFocusManager.current
+ var name by remember { mutableStateOf("") }
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ var nameError by remember { mutableStateOf(null) }
+ var emailError by remember { mutableStateOf(null) }
+ var passwordError by remember { mutableStateOf(null) }
+
+ fun validate(): Boolean {
+ var valid = true
+ nameError = if (name.isBlank()) {
+ valid = false; "Name is required"
+ } else if (name.trim().length > 100) {
+ valid = false; "Name must be 100 characters or fewer"
+ } else null
+
+ emailError = if (email.isBlank()) {
+ valid = false; "Email is required"
+ } else if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
+ valid = false; "Enter a valid email address"
+ } else null
+
+ passwordError = if (password.length < 8) {
+ valid = false; "Password must be at least 8 characters"
+ } else if (!password.any { it.isLetter() } || !password.any { it.isDigit() }) {
+ valid = false; "Password must contain letters and numbers"
+ } else null
+
+ return valid
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(48.dp))
+
+ Text(
+ text = "Create account",
+ style = MaterialTheme.typography.displayMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "Let's get your pantry set up.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ AnimatedVisibility(visible = uiState.errorMessage != null) {
+ uiState.errorMessage?.let { msg ->
+ ErrorBanner(message = msg, onDismiss = onClearError)
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it; nameError = null; onClearError() },
+ label = { Text("Full name") },
+ leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
+ isError = nameError != null,
+ supportingText = nameError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Next,
+ capitalization = KeyboardCapitalization.Words
+ ),
+ keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it; emailError = null; onClearError() },
+ label = { Text("Email") },
+ leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
+ isError = emailError != null,
+ supportingText = emailError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it; passwordError = null; onClearError() },
+ label = { Text("Password") },
+ leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ )
+ }
+ },
+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ isError = passwordError != null,
+ supportingText = passwordError?.let { { Text(it) } } ?: { Text("8+ characters, letters and numbers") },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ if (validate()) onSignUp(email.trim(), password, name.trim())
+ }
+ ),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ if (validate()) onSignUp(email.trim(), password, name.trim())
+ },
+ enabled = !uiState.isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Create account", style = MaterialTheme.typography.titleMedium)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ Text(" or ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedButton(
+ onClick = onGoogleSignIn,
+ enabled = !uiState.isLoading,
+ modifier = Modifier.fillMaxWidth().height(52.dp)
+ ) {
+ Icon(Icons.Default.AccountCircle, contentDescription = null, modifier = Modifier.size(20.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Continue with Google", style = MaterialTheme.typography.titleMedium)
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "Already have an account?",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ TextButton(onClick = onNavigateToSignIn) { Text("Sign in") }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// FORGOT PASSWORD SCREEN
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun ForgotPasswordScreen(
+ uiState: AuthUiState,
+ onRequestReset: (email: String) -> Unit,
+ onNavigateBack: () -> Unit,
+ onClearError: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val focusManager = LocalFocusManager.current
+ var email by remember { mutableStateOf("") }
+ var emailError by remember { mutableStateOf(null) }
+ var emailSent by remember { mutableStateOf(false) }
+
+ // Watch for success event via uiState — screen shows confirmation inline
+ LaunchedEffect(uiState.successMessage) {
+ if (uiState.successMessage != null) emailSent = true
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(48.dp))
+
+ if (emailSent) {
+ // Success state
+ Icon(
+ imageVector = Icons.Default.MarkEmailRead,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(72.dp)
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = "Check your inbox",
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "If an account exists for $email, we've sent a reset link. It expires in 1 hour.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(onClick = onNavigateBack, modifier = Modifier.fillMaxWidth().height(52.dp)) {
+ Text("Back to sign in")
+ }
+ } else {
+ // Request state
+ Icon(
+ imageVector = Icons.Default.LockReset,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(56.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Reset your password",
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Enter your email and we'll send you a reset link.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+
+ AnimatedVisibility(visible = uiState.errorMessage != null) {
+ uiState.errorMessage?.let { msg ->
+ ErrorBanner(message = msg, onDismiss = onClearError)
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it; emailError = null; onClearError() },
+ label = { Text("Email") },
+ leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
+ isError = emailError != null,
+ supportingText = emailError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ if (email.isBlank()) {
+ emailError = "Email is required"
+ } else {
+ onRequestReset(email.trim())
+ }
+ }
+ ),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ if (email.isBlank()) {
+ emailError = "Email is required"
+ } else {
+ onRequestReset(email.trim())
+ }
+ },
+ enabled = !uiState.isLoading,
+ modifier = Modifier.fillMaxWidth().height(52.dp)
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
+ } else {
+ Text("Send reset link", style = MaterialTheme.typography.titleMedium)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ TextButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Back to sign in")
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ACCOUNT RESTORE SCREEN
+// Shown when user signs in on a soft-deleted account.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun AccountRestoreScreen(
+ uiState: AuthUiState,
+ deletionScheduledAt: String,
+ onRestore: () -> Unit,
+ onSignOut: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.RestoreFromTrash,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier.size(72.dp)
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = "Your account is scheduled for deletion",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "It will be permanently deleted on ${formatDeletionDate(deletionScheduledAt)}. " +
+ "Restore it now to keep all your data.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ AnimatedVisibility(visible = uiState.errorMessage != null) {
+ uiState.errorMessage?.let { msg ->
+ Spacer(modifier = Modifier.height(16.dp))
+ ErrorBanner(message = msg, onDismiss = {})
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = onRestore,
+ enabled = !uiState.isLoading,
+ modifier = Modifier.fillMaxWidth().height(52.dp)
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
+ } else {
+ Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Restore my account", style = MaterialTheme.typography.titleMedium)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ OutlinedButton(
+ onClick = onSignOut,
+ enabled = !uiState.isLoading,
+ modifier = Modifier.fillMaxWidth().height(52.dp)
+ ) {
+ Text("No thanks, sign out")
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SHARED COMPONENTS
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ shape = MaterialTheme.shapes.small,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.Top,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(
+ onClick = onDismiss,
+ modifier = Modifier.size(20.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Dismiss",
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+ }
+}
+
+private fun formatDeletionDate(isoTimestamp: String): String {
+ return try {
+ val instant = java.time.Instant.parse(isoTimestamp)
+ val local = instant.atZone(java.time.ZoneId.systemDefault())
+ java.time.format.DateTimeFormatter.ofPattern("MMMM d, yyyy").format(local)
+ } catch (e: Exception) {
+ isoTimestamp
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/auth/AuthViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/auth/AuthViewModel.kt
new file mode 100644
index 0000000..7c81d12
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/auth/AuthViewModel.kt
@@ -0,0 +1,225 @@
+package com.pantree.app.ui.auth
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.repository.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+// ─── UI State ─────────────────────────────────────────────────────────────────
+
+data class AuthUiState(
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val successMessage: String? = null,
+ // For account-pending-deletion flow
+ val pendingDeletionScheduledAt: String? = null,
+ val canRestore: Boolean = false
+)
+
+sealed class AuthEvent {
+ object NavigateToHome : AuthEvent()
+ data class NavigateToRestore(val deletionScheduledAt: String) : AuthEvent()
+ object NavigateToSignIn : AuthEvent()
+ object PasswordResetEmailSent : AuthEvent()
+ object PasswordResetComplete : AuthEvent()
+ object AccountDeleted : AuthEvent()
+ object AccountRestored : AuthEvent()
+}
+
+// ─── ViewModel ────────────────────────────────────────────────────────────────
+
+@HiltViewModel
+class AuthViewModel @Inject constructor(
+ private val authRepository: AuthRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(AuthUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ fun isLoggedIn(): Boolean = authRepository.isLoggedIn()
+
+ fun signup(email: String, password: String, name: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+ when (val result = authRepository.signup(email, password, name)) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.NavigateToHome)
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = friendlyError(result.code, result.message)
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun signin(email: String, password: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+ when (val result = authRepository.signin(email, password)) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.NavigateToHome)
+ }
+ is NetworkResult.Error -> {
+ if (result.code == "ACCOUNT_PENDING_DELETION") {
+ val scheduledAt = result.extra?.deletionScheduledAt ?: ""
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ pendingDeletionScheduledAt = scheduledAt,
+ canRestore = result.extra?.canRestore ?: false
+ )
+ }
+ _events.emit(AuthEvent.NavigateToRestore(scheduledAt))
+ } else {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = friendlyError(result.code, result.message)
+ )
+ }
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun googleAuth(idToken: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+ when (val result = authRepository.googleAuth(idToken)) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.NavigateToHome)
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = friendlyError(result.code, result.message)
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun requestPasswordReset(email: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+ when (authRepository.requestPasswordReset(email)) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.PasswordResetEmailSent)
+ }
+ is NetworkResult.Error -> {
+ // Server always returns 200 — this only fires on network failure
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = "Couldn't send the reset email. Check your connection and try again."
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun confirmPasswordReset(token: String, newPassword: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+ when (val result = authRepository.confirmPasswordReset(token, newPassword)) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.PasswordResetComplete)
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = friendlyError(result.code, result.message)
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun deleteAccount() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+ when (authRepository.deleteAccount()) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.AccountDeleted)
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = "Couldn't delete your account right now. Please try again."
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun restoreAccount() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+ when (val result = authRepository.restoreAccount()) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.AccountRestored)
+ }
+ is NetworkResult.Error -> {
+ val message = if (result.httpStatus == 410) {
+ "This account has been permanently deleted and can't be recovered."
+ } else {
+ "Couldn't restore your account. Please try again."
+ }
+ _uiState.update { it.copy(isLoading = false, errorMessage = message) }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun signOut() {
+ authRepository.signOut()
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+
+ private fun friendlyError(code: String, serverMessage: String): String = when (code) {
+ "CONFLICT" -> "An account with that email already exists. Try signing in instead."
+ "UNAUTHORIZED" -> "That email and password don't match. Double-check and try again."
+ "INVALID_TOKEN" -> "That link has expired or already been used. Request a new one."
+ "VALIDATION_ERROR" -> serverMessage
+ "NO_CONNECTION" -> "No internet connection. Please check your network."
+ "TIMEOUT" -> "The request timed out. Please try again."
+ else -> serverMessage.ifBlank { "Something went wrong. Please try again." }
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/components/CommonComponents.kt b/android/app/src/main/java/com/pantree/app/ui/components/CommonComponents.kt
new file mode 100644
index 0000000..beab3be
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/components/CommonComponents.kt
@@ -0,0 +1,339 @@
+package com.pantree.app.ui.components
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.layout.*
+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.vector.ImageVector
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+
+// ─────────────────────────────────────────────────────────────────────────────
+// LOADING STATE
+// Used on every screen while data is being fetched.
+// Not a spinner in the middle of nowhere — it has context.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun LoadingState(
+ message: String = "Loading…",
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.primary,
+ strokeWidth = 3.dp,
+ modifier = Modifier.size(40.dp)
+ )
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// INLINE LOADING (for buttons, small areas)
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun InlineLoading(modifier: Modifier = Modifier) {
+ CircularProgressIndicator(
+ modifier = modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ERROR STATE
+// Honest about what went wrong. Gives the user something to do about it.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun ErrorState(
+ message: String,
+ onRetry: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.padding(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.ErrorOutline,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(48.dp)
+ )
+ Text(
+ text = "Something went wrong",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center
+ )
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ if (onRetry != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Button(onClick = onRetry) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Try again")
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// EMPTY STATE
+// The pantry is empty. The list is empty. That's fine — tell them what to do.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun EmptyState(
+ icon: ImageVector,
+ title: String,
+ subtitle: String,
+ actionLabel: String? = null,
+ onAction: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.padding(32.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ modifier = Modifier.size(64.dp)
+ )
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ if (actionLabel != null && onAction != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Button(onClick = onAction) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(actionLabel)
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// OFFLINE BANNER
+// Shown at the top of screens when there's no connection.
+// Read-only mode — no edits while offline.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun OfflineBanner(modifier: Modifier = Modifier) {
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.WifiOff,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text = "You're offline. Showing saved data — changes are paused.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SYNC INDICATOR
+// Subtle. Doesn't interrupt. Just lets them know something is happening.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun SyncingIndicator(
+ isSyncing: Boolean,
+ modifier: Modifier = Modifier
+) {
+ AnimatedVisibility(
+ visible = isSyncing,
+ enter = fadeIn() + slideInVertically(),
+ exit = fadeOut() + slideOutVertically(),
+ modifier = modifier
+ ) {
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(14.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ text = "Syncing…",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SNACKBAR HOST — used for transient feedback
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun PantreeSnackbarHost(
+ hostState: SnackbarHostState,
+ modifier: Modifier = Modifier
+) {
+ SnackbarHost(
+ hostState = hostState,
+ modifier = modifier,
+ snackbar = { data ->
+ Snackbar(
+ snackbarData = data,
+ containerColor = MaterialTheme.colorScheme.inverseSurface,
+ contentColor = MaterialTheme.colorScheme.inverseOnSurface,
+ actionColor = MaterialTheme.colorScheme.inversePrimary,
+ shape = RoundedCornerShape(8.dp)
+ )
+ }
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// CONFIRM DELETE DIALOG
+// "Are you sure?" — but written like a human, not a legal document.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun ConfirmDeleteDialog(
+ title: String,
+ message: String,
+ confirmLabel: String = "Delete",
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ icon = {
+ Icon(
+ imageVector = Icons.Default.DeleteForever,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ },
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ },
+ confirmButton = {
+ Button(
+ onClick = onConfirm,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text(confirmLabel)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SECTION HEADER
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun SectionHeader(
+ title: String,
+ modifier: Modifier = Modifier
+) {
+ Text(
+ text = title.uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/navigation/NavGraph.kt b/android/app/src/main/java/com/pantree/app/ui/navigation/NavGraph.kt
new file mode 100644
index 0000000..c7d832b
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/navigation/NavGraph.kt
@@ -0,0 +1,583 @@
+package com.pantree.app.ui.navigation
+
+import androidx.compose.animation.*
+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.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.*
+import androidx.navigation.compose.*
+import com.pantree.app.ui.auth.*
+import com.pantree.app.ui.pantry.*
+import com.pantree.app.ui.recipes.*
+import com.pantree.app.ui.settings.*
+import com.pantree.app.ui.shopping.*
+import kotlinx.coroutines.flow.collectLatest
+import java.net.URLDecoder
+
+// ─────────────────────────────────────────────────────────────────────────────
+// BOTTOM NAV ITEMS
+// ─────────────────────────────────────────────────────────────────────────────
+
+data class BottomNavItem(
+ val screen: Screen,
+ val label: String,
+ val icon: ImageVector,
+ val selectedIcon: ImageVector = icon
+)
+
+val bottomNavItems = listOf(
+ BottomNavItem(Screen.Pantry, "Pantry", Icons.Default.Kitchen),
+ BottomNavItem(Screen.Recipes, "Recipes", Icons.Default.MenuBook),
+ BottomNavItem(Screen.ShoppingLists, "Lists", Icons.Default.ShoppingCart),
+ BottomNavItem(Screen.Settings, "Settings", Icons.Default.Settings)
+)
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ROOT NAV HOST
+// Decides whether to show auth or main flow based on token validity.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun PantreeNavHost(
+ isLoggedIn: Boolean,
+ isOffline: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val navController = rememberNavController()
+ val startDestination = if (isLoggedIn) "main" else "auth"
+
+ NavHost(
+ navController = navController,
+ startDestination = startDestination,
+ modifier = modifier,
+ enterTransition = { fadeIn() + slideInHorizontally { it / 4 } },
+ exitTransition = { fadeOut() + slideOutHorizontally { -it / 4 } },
+ popEnterTransition = { fadeIn() + slideInHorizontally { -it / 4 } },
+ popExitTransition = { fadeOut() + slideOutHorizontally { it / 4 } }
+ ) {
+ // ── Auth graph ────────────────────────────────────────────────────────
+ navigation(startDestination = Screen.SignIn.route, route = "auth") {
+ composable(Screen.SignIn.route) {
+ val viewModel: AuthViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is AuthEvent.NavigateToHome -> {
+ navController.navigate("main") {
+ popUpTo("auth") { inclusive = true }
+ }
+ }
+ is AuthEvent.NavigateToRestore -> {
+ navController.navigate(
+ Screen.AccountRestore.createRoute(event.deletionScheduledAt)
+ )
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ SignInScreen(
+ uiState = uiState,
+ onSignIn = { email, password -> viewModel.signin(email, password) },
+ onNavigateToSignUp = { navController.navigate(Screen.SignUp.route) },
+ onNavigateToForgotPassword = { navController.navigate(Screen.ForgotPassword.route) },
+ onGoogleSignIn = { /* Google Sign-In launcher handled at Activity level */ },
+ onClearError = viewModel::clearError
+ )
+ }
+
+ composable(Screen.SignUp.route) {
+ val viewModel: AuthViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is AuthEvent.NavigateToHome -> {
+ navController.navigate("main") {
+ popUpTo("auth") { inclusive = true }
+ }
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ SignUpScreen(
+ uiState = uiState,
+ onSignUp = { email, password, name -> viewModel.signup(email, password, name) },
+ onNavigateToSignIn = { navController.popBackStack() },
+ onGoogleSignIn = { /* Google Sign-In launcher */ },
+ onClearError = viewModel::clearError
+ )
+ }
+
+ composable(Screen.ForgotPassword.route) {
+ val viewModel: AuthViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is AuthEvent.PasswordResetEmailSent -> {
+ // Screen handles success state inline — no nav needed
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ ForgotPasswordScreen(
+ uiState = uiState,
+ onRequestReset = { email -> viewModel.requestPasswordReset(email) },
+ onNavigateBack = { navController.popBackStack() },
+ onClearError = viewModel::clearError
+ )
+ }
+
+ composable(
+ route = Screen.AccountRestore.route,
+ arguments = listOf(navArgument("deletion_scheduled_at") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val viewModel: AuthViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val deletionScheduledAt = URLDecoder.decode(
+ backStackEntry.arguments?.getString("deletion_scheduled_at") ?: "",
+ "UTF-8"
+ )
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is AuthEvent.AccountRestored -> {
+ navController.navigate("main") {
+ popUpTo("auth") { inclusive = true }
+ }
+ }
+ is AuthEvent.AccountDeleted -> {
+ navController.navigate(Screen.SignIn.route) {
+ popUpTo("auth") { inclusive = false }
+ }
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ AccountRestoreScreen(
+ uiState = uiState,
+ deletionScheduledAt = deletionScheduledAt,
+ onRestore = viewModel::restoreAccount,
+ onSignOut = {
+ viewModel.signOut()
+ navController.navigate(Screen.SignIn.route) {
+ popUpTo("auth") { inclusive = false }
+ }
+ }
+ )
+ }
+ }
+
+ // ── Main graph (bottom nav) ───────────────────────────────────────────
+ navigation(startDestination = Screen.Pantry.route, route = "main") {
+ composable(Screen.Pantry.route) {
+ val viewModel: PantryViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is PantryEvent.ItemAdded -> snackbarHostState.showSnackbar("\"${event.itemName}\" added to pantry")
+ is PantryEvent.ItemUpdated -> snackbarHostState.showSnackbar("Quantity updated")
+ is PantryEvent.ItemDeleted -> snackbarHostState.showSnackbar("\"${event.itemName}\" removed")
+ is PantryEvent.Error -> snackbarHostState.showSnackbar(event.message)
+ }
+ }
+ }
+
+ PantryScreen(
+ uiState = uiState,
+ onRefresh = viewModel::refresh,
+ onAddItem = viewModel::addItem,
+ onUpdateItem = viewModel::updateItem,
+ onDeleteItem = viewModel::deleteItem,
+ onClearError = viewModel::clearError,
+ onClearSnackbar = viewModel::clearSnackbar,
+ onClearDuplicateConflict = viewModel::clearDuplicateConflict,
+ isOffline = isOffline
+ )
+ }
+
+ composable(Screen.Recipes.route) {
+ val viewModel: RecipesViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ RecipesScreen(
+ uiState = uiState,
+ onRefresh = viewModel::refresh,
+ onFilterChange = viewModel::setFilter,
+ onSearchChange = viewModel::setSearchQuery,
+ onRecipeClick = { recipeId ->
+ viewModel.loadRecipeDetail(recipeId)
+ navController.navigate(Screen.RecipeDetail.createRoute(recipeId))
+ },
+ onClearError = viewModel::clearError,
+ isOffline = isOffline
+ )
+ }
+
+ composable(
+ route = Screen.RecipeDetail.route,
+ arguments = listOf(navArgument("recipe_id") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val recipeId = backStackEntry.arguments?.getString("recipe_id") ?: return@composable
+ // Share the ViewModel with the recipes list screen via the nav back stack entry
+ val parentEntry = remember(backStackEntry) {
+ navController.getBackStackEntry(Screen.Recipes.route)
+ }
+ val viewModel: RecipesViewModel = hiltViewModel(parentEntry)
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val shoppingViewModel: ShoppingViewModel = hiltViewModel()
+
+ LaunchedEffect(Unit) {
+ shoppingViewModel.events.collectLatest { event ->
+ when (event) {
+ is ShoppingEvent.RecipesAdded -> {
+ // Navigate back to shopping lists after adding
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ RecipeDetailScreen(
+ recipeId = recipeId,
+ uiState = uiState,
+ onBack = {
+ viewModel.clearDetail()
+ navController.popBackStack()
+ },
+ onScaleChange = { scale -> viewModel.setScale(recipeId, scale) },
+ onAddToShoppingList = { rId, scale ->
+ // Navigate to shopping lists to pick a list
+ navController.navigate(Screen.ShoppingLists.route)
+ }
+ )
+ }
+
+ composable(Screen.ShoppingLists.route) {
+ val viewModel: ShoppingViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is ShoppingEvent.ListCreated -> {
+ navController.navigate(
+ Screen.ShoppingListDetail.createRoute(event.listId, event.listName)
+ )
+ }
+ is ShoppingEvent.ListDeleted -> snackbarHostState.showSnackbar("\"${event.listName}\" deleted")
+ is ShoppingEvent.Error -> snackbarHostState.showSnackbar(event.message)
+ else -> Unit
+ }
+ }
+ }
+
+ ShoppingListsScreen(
+ uiState = uiState,
+ onRefresh = viewModel::refreshLists,
+ onCreateList = viewModel::createList,
+ onDeleteList = viewModel::deleteList,
+ onListClick = { id, name ->
+ viewModel.loadListDetail(id)
+ navController.navigate(Screen.ShoppingListDetail.createRoute(id, name))
+ },
+ onClearError = viewModel::clearListsError,
+ onClearSnackbar = viewModel::clearSnackbar,
+ isOffline = isOffline
+ )
+ }
+
+ composable(
+ route = Screen.ShoppingListDetail.route,
+ arguments = listOf(
+ navArgument("list_id") { type = NavType.StringType },
+ navArgument("list_name") { type = NavType.StringType }
+ )
+ ) { backStackEntry ->
+ val listId = backStackEntry.arguments?.getString("list_id") ?: return@composable
+ val listName = URLDecoder.decode(
+ backStackEntry.arguments?.getString("list_name") ?: "",
+ "UTF-8"
+ )
+ val parentEntry = remember(backStackEntry) {
+ navController.getBackStackEntry(Screen.ShoppingLists.route)
+ }
+ val viewModel: ShoppingViewModel = hiltViewModel(parentEntry)
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is ShoppingEvent.ItemAdded -> {
+ val msg = if (event.merged) "Quantities merged for \"${event.itemName}\""
+ else "\"${event.itemName}\" added"
+ snackbarHostState.showSnackbar(msg)
+ }
+ is ShoppingEvent.ItemDeleted -> snackbarHostState.showSnackbar("\"${event.itemName}\" removed")
+ is ShoppingEvent.ListDeleted -> navController.popBackStack()
+ is ShoppingEvent.Error -> snackbarHostState.showSnackbar(event.message)
+ else -> Unit
+ }
+ }
+ }
+
+ ShoppingListDetailScreen(
+ listId = listId,
+ uiState = uiState.copy(currentListName = uiState.currentListName.ifBlank { listName }),
+ onBack = { navController.popBackStack() },
+ onAddItem = { name, qty, unit -> viewModel.addItem(listId, name, qty, unit) },
+ onToggleItem = { item -> viewModel.toggleItemChecked(listId, item) },
+ onDeleteItem = { item -> viewModel.deleteItem(listId, item) },
+ onDeleteList = { viewModel.deleteList(listId, listName) },
+ onClearError = viewModel::clearDetailError,
+ onClearSnackbar = viewModel::clearSnackbar,
+ isOffline = isOffline
+ )
+ }
+
+ composable(Screen.Settings.route) {
+ val viewModel: SettingsViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is SettingsEvent.SignedOut, is SettingsEvent.AccountDeleted -> {
+ navController.navigate("auth") {
+ popUpTo("main") { inclusive = true }
+ }
+ }
+ }
+ }
+ }
+
+ SettingsScreen(
+ uiState = uiState,
+ onSyncNow = viewModel::syncNow,
+ onSignOut = viewModel::signOut,
+ onDeleteAccount = viewModel::deleteAccount,
+ onShowDeleteConfirm = viewModel::showDeleteConfirm,
+ onHideDeleteConfirm = viewModel::hideDeleteConfirm,
+ onClearError = viewModel::clearError,
+ onClearSnackbar = viewModel::clearSnackbar
+ )
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MAIN SCAFFOLD WITH BOTTOM NAV
+// Wraps the main graph screens with persistent bottom navigation.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun MainScaffold(
+ isOffline: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val navController = rememberNavController()
+ val currentBackStack by navController.currentBackStackEntryAsState()
+ val currentRoute = currentBackStack?.destination?.route
+
+ val showBottomBar = bottomNavItems.any { it.screen.route == currentRoute }
+
+ Scaffold(
+ bottomBar = {
+ if (showBottomBar) {
+ NavigationBar {
+ bottomNavItems.forEach { item ->
+ NavigationBarItem(
+ selected = currentRoute == item.screen.route,
+ onClick = {
+ navController.navigate(item.screen.route) {
+ popUpTo(navController.graph.startDestinationId) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ icon = {
+ Icon(
+ imageVector = if (currentRoute == item.screen.route)
+ item.selectedIcon else item.icon,
+ contentDescription = item.label
+ )
+ },
+ label = { Text(item.label) }
+ )
+ }
+ }
+ }
+ },
+ modifier = modifier
+ ) { paddingValues ->
+ NavHost(
+ navController = navController,
+ startDestination = Screen.Pantry.route,
+ modifier = Modifier.padding(paddingValues),
+ enterTransition = { fadeIn() },
+ exitTransition = { fadeOut() }
+ ) {
+ composable(Screen.Pantry.route) {
+ val viewModel: PantryViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ PantryScreen(
+ uiState = uiState,
+ onRefresh = viewModel::refresh,
+ onAddItem = viewModel::addItem,
+ onUpdateItem = viewModel::updateItem,
+ onDeleteItem = viewModel::deleteItem,
+ onClearError = viewModel::clearError,
+ onClearSnackbar = viewModel::clearSnackbar,
+ onClearDuplicateConflict = viewModel::clearDuplicateConflict,
+ isOffline = isOffline
+ )
+ }
+ composable(Screen.Recipes.route) {
+ val viewModel: RecipesViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ RecipesScreen(
+ uiState = uiState,
+ onRefresh = viewModel::refresh,
+ onFilterChange = viewModel::setFilter,
+ onSearchChange = viewModel::setSearchQuery,
+ onRecipeClick = { recipeId ->
+ viewModel.loadRecipeDetail(recipeId)
+ navController.navigate(Screen.RecipeDetail.createRoute(recipeId))
+ },
+ onClearError = viewModel::clearError,
+ isOffline = isOffline
+ )
+ }
+ composable(
+ route = Screen.RecipeDetail.route,
+ arguments = listOf(navArgument("recipe_id") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val recipeId = backStackEntry.arguments?.getString("recipe_id") ?: return@composable
+ val parentEntry = remember(backStackEntry) {
+ navController.getBackStackEntry(Screen.Recipes.route)
+ }
+ val viewModel: RecipesViewModel = hiltViewModel(parentEntry)
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ RecipeDetailScreen(
+ recipeId = recipeId,
+ uiState = uiState,
+ onBack = { viewModel.clearDetail(); navController.popBackStack() },
+ onScaleChange = { scale -> viewModel.setScale(recipeId, scale) },
+ onAddToShoppingList = { _, _ -> navController.navigate(Screen.ShoppingLists.route) }
+ )
+ }
+ composable(Screen.ShoppingLists.route) {
+ val viewModel: ShoppingViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ if (event is ShoppingEvent.ListCreated) {
+ navController.navigate(
+ Screen.ShoppingListDetail.createRoute(event.listId, event.listName)
+ )
+ }
+ }
+ }
+ ShoppingListsScreen(
+ uiState = uiState,
+ onRefresh = viewModel::refreshLists,
+ onCreateList = viewModel::createList,
+ onDeleteList = viewModel::deleteList,
+ onListClick = { id, name ->
+ viewModel.loadListDetail(id)
+ navController.navigate(Screen.ShoppingListDetail.createRoute(id, name))
+ },
+ onClearError = viewModel::clearListsError,
+ onClearSnackbar = viewModel::clearSnackbar,
+ isOffline = isOffline
+ )
+ }
+ composable(
+ route = Screen.ShoppingListDetail.route,
+ arguments = listOf(
+ navArgument("list_id") { type = NavType.StringType },
+ navArgument("list_name") { type = NavType.StringType }
+ )
+ ) { backStackEntry ->
+ val listId = backStackEntry.arguments?.getString("list_id") ?: return@composable
+ val listName = URLDecoder.decode(
+ backStackEntry.arguments?.getString("list_name") ?: "", "UTF-8"
+ )
+ val parentEntry = remember(backStackEntry) {
+ navController.getBackStackEntry(Screen.ShoppingLists.route)
+ }
+ val viewModel: ShoppingViewModel = hiltViewModel(parentEntry)
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ if (event is ShoppingEvent.ListDeleted) navController.popBackStack()
+ }
+ }
+ ShoppingListDetailScreen(
+ listId = listId,
+ uiState = uiState.copy(currentListName = uiState.currentListName.ifBlank { listName }),
+ onBack = { navController.popBackStack() },
+ onAddItem = { name, qty, unit -> viewModel.addItem(listId, name, qty, unit) },
+ onToggleItem = { item -> viewModel.toggleItemChecked(listId, item) },
+ onDeleteItem = { item -> viewModel.deleteItem(listId, item) },
+ onDeleteList = { viewModel.deleteList(listId, listName) },
+ onClearError = viewModel::clearDetailError,
+ onClearSnackbar = viewModel::clearSnackbar,
+ isOffline = isOffline
+ )
+ }
+ composable(Screen.Settings.route) {
+ val viewModel: SettingsViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is SettingsEvent.SignedOut, is SettingsEvent.AccountDeleted -> {
+ navController.navigate("auth") {
+ popUpTo(0) { inclusive = true }
+ }
+ }
+ }
+ }
+ }
+ SettingsScreen(
+ uiState = uiState,
+ onSyncNow = viewModel::syncNow,
+ onSignOut = viewModel::signOut,
+ onDeleteAccount = viewModel::deleteAccount,
+ onShowDeleteConfirm = viewModel::showDeleteConfirm,
+ onHideDeleteConfirm = viewModel::hideDeleteConfirm,
+ onClearError = viewModel::clearError,
+ onClearSnackbar = viewModel::clearSnackbar
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/navigation/Screen.kt b/android/app/src/main/java/com/pantree/app/ui/navigation/Screen.kt
new file mode 100644
index 0000000..de82802
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/navigation/Screen.kt
@@ -0,0 +1,28 @@
+package com.pantree.app.ui.navigation
+
+// All named routes in the app.
+// Sealed class keeps them in one place — no magic strings scattered around.
+
+sealed class Screen(val route: String) {
+ // Auth
+ object SignIn : Screen("sign_in")
+ object SignUp : Screen("sign_up")
+ object ForgotPassword : Screen("forgot_password")
+ object AccountRestore : Screen("account_restore/{deletion_scheduled_at}") {
+ fun createRoute(deletionScheduledAt: String) =
+ "account_restore/${java.net.URLEncoder.encode(deletionScheduledAt, "UTF-8")}"
+ }
+
+ // Main (bottom nav)
+ object Pantry : Screen("pantry")
+ object Recipes : Screen("recipes")
+ object RecipeDetail : Screen("recipe_detail/{recipe_id}") {
+ fun createRoute(recipeId: String) = "recipe_detail/$recipeId"
+ }
+ object ShoppingLists : Screen("shopping_lists")
+ object ShoppingListDetail : Screen("shopping_list_detail/{list_id}/{list_name}") {
+ fun createRoute(listId: String, listName: String) =
+ "shopping_list_detail/$listId/${java.net.URLEncoder.encode(listName, "UTF-8")}"
+ }
+ object Settings : Screen("settings")
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/pantry/PantryScreen.kt b/android/app/src/main/java/com/pantree/app/ui/pantry/PantryScreen.kt
new file mode 100644
index 0000000..da51632
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/pantry/PantryScreen.kt
@@ -0,0 +1,653 @@
+package com.pantree.app.ui.pantry
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+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.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.material3.rememberSwipeToDismissBoxState
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import com.pantree.app.data.local.entity.PantryItemEntity
+import com.pantree.app.ui.components.*
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PANTRY SCREEN — the main list
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PantryScreen(
+ uiState: PantryUiState,
+ onRefresh: () -> Unit,
+ onAddItem: (name: String, quantity: Int) -> Unit,
+ onUpdateItem: (id: String, quantity: Int) -> Unit,
+ onDeleteItem: (PantryItemEntity) -> Unit,
+ onClearError: () -> Unit,
+ onClearSnackbar: () -> Unit,
+ onClearDuplicateConflict: () -> Unit,
+ isOffline: Boolean = false,
+ modifier: Modifier = Modifier
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+ var showAddSheet by remember { mutableStateOf(false) }
+ var editingItem by remember { mutableStateOf(null) }
+ var deleteTarget by remember { mutableStateOf(null) }
+ var searchQuery by remember { mutableStateOf("") }
+
+ // Show snackbar messages
+ LaunchedEffect(uiState.snackbarMessage) {
+ uiState.snackbarMessage?.let {
+ snackbarHostState.showSnackbar(it)
+ onClearSnackbar()
+ }
+ }
+
+ val filteredItems = remember(uiState.items, searchQuery) {
+ if (searchQuery.isBlank()) uiState.items
+ else uiState.items.filter { it.itemName.contains(searchQuery, ignoreCase = true) }
+ }
+
+ Scaffold(
+ topBar = {
+ Column {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Pantry",
+ style = MaterialTheme.typography.headlineMedium
+ )
+ },
+ actions = {
+ IconButton(onClick = onRefresh) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh pantry")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background
+ )
+ )
+ if (isOffline) OfflineBanner()
+ if (uiState.isRefreshing) SyncingIndicator(isSyncing = true)
+ }
+ },
+ floatingActionButton = {
+ if (!isOffline) {
+ ExtendedFloatingActionButton(
+ onClick = { showAddSheet = true },
+ icon = { Icon(Icons.Default.Add, contentDescription = null) },
+ text = { Text("Add item") },
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ },
+ snackbarHost = { PantreeSnackbarHost(hostState = snackbarHostState) },
+ modifier = modifier
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Search bar — only shown when there are items
+ if (uiState.items.isNotEmpty()) {
+ SearchBar(
+ query = searchQuery,
+ onQueryChange = { searchQuery = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
+
+ // Error banner
+ AnimatedVisibility(visible = uiState.errorMessage != null) {
+ uiState.errorMessage?.let { msg ->
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text = msg,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = onClearError, modifier = Modifier.size(24.dp)) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Dismiss",
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Content
+ when {
+ uiState.isRefreshing && uiState.items.isEmpty() -> {
+ LoadingState(message = "Loading your pantry…")
+ }
+ uiState.items.isEmpty() && !uiState.isRefreshing -> {
+ EmptyState(
+ icon = Icons.Default.Kitchen,
+ title = "Your pantry is empty",
+ subtitle = "Add ingredients you have on hand and we'll show you what you can cook.",
+ actionLabel = if (!isOffline) "Add your first item" else null,
+ onAction = if (!isOffline) ({ showAddSheet = true }) else null
+ )
+ }
+ filteredItems.isEmpty() && searchQuery.isNotBlank() -> {
+ EmptyState(
+ icon = Icons.Default.SearchOff,
+ title = "No results for \"$searchQuery\"",
+ subtitle = "Try a different name, or add it as a new item.",
+ actionLabel = if (!isOffline) "Add \"$searchQuery\"" else null,
+ onAction = if (!isOffline) ({
+ showAddSheet = true
+ }) else null
+ )
+ }
+ else -> {
+ PantryItemList(
+ items = filteredItems,
+ isOffline = isOffline,
+ onEdit = { editingItem = it },
+ onDelete = { deleteTarget = it }
+ )
+ }
+ }
+ }
+ }
+
+ // ── Add Item Bottom Sheet ──────────────────────────────────────────────────
+ if (showAddSheet) {
+ AddPantryItemSheet(
+ isLoading = uiState.isLoading,
+ prefillName = if (searchQuery.isNotBlank() &&
+ uiState.items.none { it.itemName.equals(searchQuery, ignoreCase = true) }
+ ) searchQuery else "",
+ onAdd = { name, qty ->
+ onAddItem(name, qty)
+ showAddSheet = false
+ },
+ onDismiss = { showAddSheet = false }
+ )
+ }
+
+ // ── Edit Item Bottom Sheet ─────────────────────────────────────────────────
+ editingItem?.let { item ->
+ EditPantryItemSheet(
+ item = item,
+ isLoading = uiState.isLoading,
+ onUpdate = { qty ->
+ onUpdateItem(item.id, qty)
+ editingItem = null
+ },
+ onDismiss = { editingItem = null }
+ )
+ }
+
+ // ── Delete Confirmation ────────────────────────────────────────────────────
+ deleteTarget?.let { item ->
+ ConfirmDeleteDialog(
+ title = "Remove from pantry?",
+ message = "\"${item.itemName}\" will be removed from your pantry. You can always add it back.",
+ confirmLabel = "Remove",
+ onConfirm = {
+ onDeleteItem(item)
+ deleteTarget = null
+ },
+ onDismiss = { deleteTarget = null }
+ )
+ }
+
+ // ── Duplicate Conflict Dialog ──────────────────────────────────────────────
+ uiState.duplicateConflict?.let { conflict ->
+ AlertDialog(
+ onDismissRequest = onClearDuplicateConflict,
+ icon = {
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.secondary
+ )
+ },
+ title = { Text("Already in your pantry") },
+ text = {
+ Text(
+ "\"${conflict.attemptedName}\" is already in your pantry. " +
+ "Tap the item to update its quantity instead.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ confirmButton = {
+ Button(onClick = onClearDuplicateConflict) { Text("Got it") }
+ }
+ )
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PANTRY ITEM LIST
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun PantryItemList(
+ items: List,
+ isOffline: Boolean,
+ onEdit: (PantryItemEntity) -> Unit,
+ onDelete: (PantryItemEntity) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 96.dp) // FAB clearance
+ ) {
+ item {
+ Text(
+ text = "${items.size} item${if (items.size != 1) "s" else ""}",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
+ items(items = items, key = { it.id }) { item ->
+ PantryItemRow(
+ item = item,
+ isOffline = isOffline,
+ onEdit = { onEdit(item) },
+ onDelete = { onDelete(item) },
+ modifier = Modifier.animateItem()
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PANTRY ITEM ROW — swipe to delete, tap to edit
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+private fun PantryItemRow(
+ item: PantryItemEntity,
+ isOffline: Boolean,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val dismissState = rememberSwipeToDismissBoxState(
+ confirmValueChange = { value ->
+ if (value == SwipeToDismissBoxValue.EndToStart && !isOffline) {
+ onDelete()
+ true
+ } else false
+ }
+ )
+
+ SwipeToDismissBox(
+ state = dismissState,
+ enableDismissFromStartToEnd = false,
+ enableDismissFromEndToStart = !isOffline,
+ backgroundContent = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.errorContainer)
+ .padding(horizontal = 20.dp),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Delete",
+ tint = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ },
+ modifier = modifier
+ ) {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = { if (!isOffline) onEdit() },
+ onLongClick = { if (!isOffline) onDelete() }
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Item icon / avatar
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = item.itemName.first().uppercaseChar().toString(),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.itemName,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Qty: ${item.quantity}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (!isOffline) {
+ Icon(
+ imageVector = Icons.Default.Edit,
+ contentDescription = "Edit",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ADD ITEM BOTTOM SHEET
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AddPantryItemSheet(
+ isLoading: Boolean,
+ prefillName: String = "",
+ onAdd: (name: String, quantity: Int) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val focusManager = LocalFocusManager.current
+ val nameFocusRequester = remember { FocusRequester() }
+
+ var name by remember { mutableStateOf(prefillName) }
+ var quantityText by remember { mutableStateOf("1") }
+ var nameError by remember { mutableStateOf(null) }
+ var quantityError by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) { nameFocusRequester.requestFocus() }
+
+ fun validate(): Boolean {
+ var valid = true
+ nameError = if (name.isBlank()) { valid = false; "Item name is required" } else null
+ quantityError = when {
+ quantityText.isBlank() -> { valid = false; "Quantity is required" }
+ quantityText.toIntOrNull() == null -> { valid = false; "Enter a whole number" }
+ (quantityText.toIntOrNull() ?: 0) <= 0 -> { valid = false; "Quantity must be at least 1" }
+ else -> null
+ }
+ return valid
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "Add to pantry",
+ style = MaterialTheme.typography.headlineSmall
+ )
+
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it; nameError = null },
+ label = { Text("Item name") },
+ leadingIcon = { Icon(Icons.Default.LocalGroceryStore, contentDescription = null) },
+ isError = nameError != null,
+ supportingText = nameError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.Words,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) }),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(nameFocusRequester)
+ )
+
+ OutlinedTextField(
+ value = quantityText,
+ onValueChange = { quantityText = it; quantityError = null },
+ label = { Text("Quantity") },
+ leadingIcon = { Icon(Icons.Default.Numbers, contentDescription = null) },
+ isError = quantityError != null,
+ supportingText = quantityError?.let { { Text(it) } } ?: { Text("Whole numbers only") },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ if (validate()) onAdd(name.trim(), quantityText.toInt())
+ }
+ ),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f).height(52.dp)
+ ) { Text("Cancel") }
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ if (validate()) onAdd(name.trim(), quantityText.toInt())
+ },
+ enabled = !isLoading,
+ modifier = Modifier.weight(1f).height(52.dp)
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
+ } else {
+ Text("Add to pantry")
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// EDIT ITEM BOTTOM SHEET
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun EditPantryItemSheet(
+ item: PantryItemEntity,
+ isLoading: Boolean,
+ onUpdate: (quantity: Int) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val focusManager = LocalFocusManager.current
+ var quantityText by remember { mutableStateOf(item.quantity.toString()) }
+ var quantityError by remember { mutableStateOf(null) }
+
+ fun validate(): Boolean {
+ quantityError = when {
+ quantityText.isBlank() -> "Quantity is required"
+ quantityText.toIntOrNull() == null -> "Enter a whole number"
+ (quantityText.toIntOrNull() ?: 0) <= 0 -> "Quantity must be at least 1"
+ else -> null
+ }
+ return quantityError == null
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "Edit quantity",
+ style = MaterialTheme.typography.headlineSmall
+ )
+ Text(
+ text = item.itemName,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "Item name can't be changed — delete and re-add if you need a different name.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ OutlinedTextField(
+ value = quantityText,
+ onValueChange = { quantityText = it; quantityError = null },
+ label = { Text("Quantity") },
+ leadingIcon = { Icon(Icons.Default.Numbers, contentDescription = null) },
+ isError = quantityError != null,
+ supportingText = quantityError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ if (validate()) onUpdate(quantityText.toInt())
+ }
+ ),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f).height(52.dp)
+ ) { Text("Cancel") }
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ if (validate()) onUpdate(quantityText.toInt())
+ },
+ enabled = !isLoading,
+ modifier = Modifier.weight(1f).height(52.dp)
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
+ } else {
+ Text("Save")
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SEARCH BAR
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun SearchBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ placeholder = { Text("Search pantry…") },
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ trailingIcon = {
+ if (query.isNotBlank()) {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(Icons.Default.Clear, contentDescription = "Clear search")
+ }
+ }
+ },
+ singleLine = true,
+ shape = RoundedCornerShape(12.dp),
+ modifier = modifier
+ )
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/pantry/PantryViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/pantry/PantryViewModel.kt
new file mode 100644
index 0000000..08fe735
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/pantry/PantryViewModel.kt
@@ -0,0 +1,154 @@
+package com.pantree.app.ui.pantry
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pantree.app.data.local.entity.PantryItemEntity
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.repository.PantryRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+// ─── UI State ─────────────────────────────────────────────────────────────────
+
+data class PantryUiState(
+ val items: List = emptyList(),
+ val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val errorMessage: String? = null,
+ val snackbarMessage: String? = null,
+ // Duplicate-item conflict: server told us the item already exists
+ val duplicateConflict: DuplicateConflict? = null
+)
+
+data class DuplicateConflict(
+ val attemptedName: String,
+ val existingItem: PantryItemEntity? = null
+)
+
+sealed class PantryEvent {
+ data class ItemAdded(val itemName: String) : PantryEvent()
+ data class ItemUpdated(val itemName: String) : PantryEvent()
+ data class ItemDeleted(val itemName: String) : PantryEvent()
+ data class Error(val message: String) : PantryEvent()
+}
+
+// ─── ViewModel ────────────────────────────────────────────────────────────────
+
+@HiltViewModel
+class PantryViewModel @Inject constructor(
+ private val pantryRepository: PantryRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(PantryUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ init {
+ // Observe local cache — always up to date, even offline
+ viewModelScope.launch {
+ pantryRepository.observePantryItems().collect { items ->
+ _uiState.update { it.copy(items = items) }
+ }
+ }
+ // Initial refresh from server
+ refresh()
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isRefreshing = true, errorMessage = null) }
+ when (val result = pantryRepository.refreshPantry()) {
+ is NetworkResult.Error -> {
+ // Don't wipe the screen — cached data is still shown
+ if (result.code != "NO_CONNECTION") {
+ _uiState.update { it.copy(errorMessage = result.message) }
+ } else {
+ _uiState.update {
+ it.copy(snackbarMessage = "Offline — showing saved data")
+ }
+ }
+ }
+ else -> Unit
+ }
+ _uiState.update { it.copy(isRefreshing = false) }
+ }
+ }
+
+ fun addItem(itemName: String, quantity: Int) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+ when (val result = pantryRepository.addItem(itemName.trim(), quantity)) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(PantryEvent.ItemAdded(itemName.trim()))
+ }
+ is NetworkResult.Error -> {
+ if (result.code == "DUPLICATE_ITEM") {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ duplicateConflict = DuplicateConflict(attemptedName = itemName.trim())
+ )
+ }
+ } else {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = result.message
+ )
+ }
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun updateItem(itemId: String, quantity: Int) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+ when (val result = pantryRepository.updateItem(itemId, quantity)) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(PantryEvent.ItemUpdated(result.data.item.itemName))
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(isLoading = false, errorMessage = result.message)
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun deleteItem(item: PantryItemEntity) {
+ viewModelScope.launch {
+ when (val result = pantryRepository.deleteItem(item.id)) {
+ is NetworkResult.Success -> {
+ _events.emit(PantryEvent.ItemDeleted(item.itemName))
+ }
+ is NetworkResult.Error -> {
+ _uiState.update { it.copy(errorMessage = result.message) }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun clearDuplicateConflict() {
+ _uiState.update { it.copy(duplicateConflict = null) }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+
+ fun clearSnackbar() {
+ _uiState.update { it.copy(snackbarMessage = null) }
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/recipes/RecipesScreen.kt b/android/app/src/main/java/com/pantree/app/ui/recipes/RecipesScreen.kt
new file mode 100644
index 0000000..eeae195
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/recipes/RecipesScreen.kt
@@ -0,0 +1,549 @@
+package com.pantree.app.ui.recipes
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.pantree.app.data.local.entity.RecipeCacheEntity
+import com.pantree.app.data.model.RecipeDetailDto
+import com.pantree.app.data.model.RecipeIngredientDto
+import com.pantree.app.ui.components.*
+import com.pantree.app.ui.theme.*
+
+// ─────────────────────────────────────────────────────────────────────────────
+// RECIPES LIST SCREEN
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RecipesScreen(
+ uiState: RecipesUiState,
+ onRefresh: () -> Unit,
+ onFilterChange: (RecipeFilter) -> Unit,
+ onSearchChange: (String) -> Unit,
+ onRecipeClick: (String) -> Unit,
+ onClearError: () -> Unit,
+ isOffline: Boolean = false,
+ modifier: Modifier = Modifier
+) {
+ Scaffold(
+ topBar = {
+ Column {
+ TopAppBar(
+ title = {
+ Text("Recipes", style = MaterialTheme.typography.headlineMedium)
+ },
+ actions = {
+ IconButton(onClick = onRefresh) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh recipes")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background
+ )
+ )
+ if (isOffline) OfflineBanner()
+ if (uiState.isRefreshing) SyncingIndicator(isSyncing = true)
+ }
+ },
+ modifier = modifier
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Search
+ OutlinedTextField(
+ value = uiState.searchQuery,
+ onValueChange = onSearchChange,
+ placeholder = { Text("Search recipes…") },
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ trailingIcon = {
+ if (uiState.searchQuery.isNotBlank()) {
+ IconButton(onClick = { onSearchChange("") }) {
+ Icon(Icons.Default.Clear, contentDescription = "Clear")
+ }
+ }
+ },
+ singleLine = true,
+ shape = RoundedCornerShape(12.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ // Filter chips
+ LazyRow(
+ contentPadding = PaddingValues(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(bottom = 8.dp)
+ ) {
+ items(RecipeFilter.values()) { filter ->
+ FilterChip(
+ selected = uiState.selectedFilter == filter,
+ onClick = { onFilterChange(filter) },
+ label = { Text(filter.label) },
+ leadingIcon = if (uiState.selectedFilter == filter) {
+ { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
+ } else null
+ )
+ }
+ }
+
+ // Error
+ AnimatedVisibility(visible = uiState.errorMessage != null) {
+ uiState.errorMessage?.let { msg ->
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(Icons.Default.Error, null, tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(18.dp))
+ Text(msg, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.weight(1f))
+ IconButton(onClick = onClearError, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Close, "Dismiss", tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(16.dp))
+ }
+ }
+ }
+ }
+ }
+
+ // Content
+ when {
+ uiState.isRefreshing && uiState.recipes.isEmpty() -> {
+ LoadingState(message = "Loading recipes…")
+ }
+ uiState.recipes.isEmpty() && !uiState.isRefreshing -> {
+ EmptyState(
+ icon = Icons.Default.MenuBook,
+ title = "No recipes yet",
+ subtitle = "Recipes will appear here once they're loaded from the server."
+ )
+ }
+ uiState.filteredRecipes.isEmpty() -> {
+ EmptyState(
+ icon = Icons.Default.SearchOff,
+ title = when (uiState.selectedFilter) {
+ RecipeFilter.CAN_MAKE -> "Nothing to cook right now"
+ RecipeFilter.PARTIAL -> "No partial matches"
+ RecipeFilter.ALL -> "No results for \"${uiState.searchQuery}\""
+ },
+ subtitle = when (uiState.selectedFilter) {
+ RecipeFilter.CAN_MAKE -> "Add more ingredients to your pantry to unlock recipes."
+ RecipeFilter.PARTIAL -> "Try adding more pantry items."
+ RecipeFilter.ALL -> "Try a different search term."
+ }
+ )
+ }
+ else -> {
+ RecipeList(
+ recipes = uiState.filteredRecipes,
+ onRecipeClick = onRecipeClick
+ )
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// RECIPE LIST
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun RecipeList(
+ recipes: List,
+ onRecipeClick: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item {
+ Text(
+ text = "${recipes.size} recipe${if (recipes.size != 1) "s" else ""}",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ }
+ items(recipes, key = { it.id }) { recipe ->
+ RecipeCard(
+ recipe = recipe,
+ onClick = { onRecipeClick(recipe.id) },
+ modifier = Modifier.animateItem()
+ )
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// RECIPE CARD
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun RecipeCard(
+ recipe: RecipeCacheEntity,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Availability indicator dot
+ Box(
+ modifier = Modifier
+ .size(10.dp)
+ .clip(RoundedCornerShape(50))
+ .background(availabilityColor(recipe.availabilityStatus))
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = recipe.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Servings
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Icon(Icons.Default.People, null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text("${recipe.servings}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ // Ingredient count
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Icon(Icons.Default.Egg, null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text("${recipe.ingredientCount} ingredients", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ Spacer(modifier = Modifier.height(6.dp))
+ AvailabilityChip(
+ status = recipe.availabilityStatus,
+ availableCount = recipe.availableCount,
+ totalCount = recipe.totalCount
+ )
+ }
+
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// RECIPE DETAIL SCREEN
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RecipeDetailScreen(
+ recipeId: String,
+ uiState: RecipesUiState,
+ onBack: () -> Unit,
+ onScaleChange: (Int) -> Unit,
+ onAddToShoppingList: (recipeId: String, scale: Int) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = uiState.selectedRecipe?.name ?: "Recipe",
+ style = MaterialTheme.typography.headlineSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background
+ )
+ )
+ },
+ modifier = modifier
+ ) { paddingValues ->
+ when {
+ uiState.isLoadingDetail -> {
+ LoadingState(
+ message = "Loading recipe…",
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ uiState.detailError != null -> {
+ ErrorState(
+ message = uiState.detailError,
+ onRetry = null,
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ uiState.selectedRecipe != null -> {
+ RecipeDetailContent(
+ recipe = uiState.selectedRecipe,
+ selectedScale = uiState.selectedScale,
+ onScaleChange = onScaleChange,
+ onAddToShoppingList = { onAddToShoppingList(recipeId, uiState.selectedScale) },
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ else -> {
+ LoadingState(
+ message = "Loading recipe…",
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// RECIPE DETAIL CONTENT
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun RecipeDetailContent(
+ recipe: RecipeDetailDto,
+ selectedScale: Int,
+ onScaleChange: (Int) -> Unit,
+ onAddToShoppingList: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 96.dp)
+ ) {
+ // Header
+ item {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)) {
+ // Availability summary
+ AvailabilityChip(
+ status = recipe.availability.status,
+ availableCount = recipe.availability.availableCount,
+ totalCount = recipe.availability.totalCount
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Servings + scale
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(Icons.Default.People, null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(
+ text = "${recipe.scaledServings} serving${if (recipe.scaledServings != 1) "s" else ""}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Scale selector
+ Text(
+ text = "Scale recipe",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf(1, 2, 3).forEach { scale ->
+ FilterChip(
+ selected = selectedScale == scale,
+ onClick = { onScaleChange(scale) },
+ label = { Text("${scale}×") }
+ )
+ }
+ }
+ }
+ }
+
+ // Ingredients section
+ item {
+ SectionHeader(title = "Ingredients", modifier = Modifier.padding(top = 8.dp))
+ }
+
+ items(recipe.ingredients) { ingredient ->
+ IngredientRow(ingredient = ingredient)
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ // Instructions section
+ item {
+ SectionHeader(title = "Instructions", modifier = Modifier.padding(top = 16.dp))
+ Text(
+ text = recipe.instructions,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
+
+ // Add to shopping list button
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = onAddToShoppingList,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .height(52.dp)
+ ) {
+ Icon(Icons.Default.AddShoppingCart, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Add ingredients to shopping list")
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// INGREDIENT ROW
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun IngredientRow(
+ ingredient: RecipeIngredientDto,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // In-pantry indicator
+ Icon(
+ imageVector = if (ingredient.inPantry) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
+ contentDescription = if (ingredient.inPantry) "In pantry" else "Not in pantry",
+ tint = if (ingredient.inPantry) CanMakeGreen else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Text(
+ text = ingredient.itemName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+
+ Text(
+ text = formatQuantity(ingredient.quantity, ingredient.unit),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// AVAILABILITY CHIP
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+fun AvailabilityChip(
+ status: String,
+ availableCount: Int,
+ totalCount: Int,
+ modifier: Modifier = Modifier
+) {
+ val (color, icon, label) = when (status) {
+ "can_make" -> Triple(CanMakeGreen, Icons.Default.CheckCircle, "Can make")
+ "partial" -> Triple(PartialYellow, Icons.Default.RemoveCircle, "$availableCount / $totalCount ingredients")
+ else -> Triple(MissingRed, Icons.Default.Cancel, "Missing ingredients")
+ }
+
+ Surface(
+ color = color.copy(alpha = 0.15f),
+ shape = RoundedCornerShape(50),
+ modifier = modifier
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(14.dp))
+ Text(label, style = MaterialTheme.typography.labelSmall, color = color)
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// HELPERS
+// ─────────────────────────────────────────────────────────────────────────────
+
+private fun availabilityColor(status: String): Color = when (status) {
+ "can_make" -> CanMakeGreen
+ "partial" -> PartialYellow
+ else -> MissingRed
+}
+
+private fun formatQuantity(quantity: Double, unit: String): String {
+ val formatted = if (quantity == quantity.toLong().toDouble()) {
+ quantity.toLong().toString()
+ } else {
+ "%.2f".format(quantity).trimEnd('0').trimEnd('.')
+ }
+ return "$formatted $unit"
+}
+
+@Composable
+private fun SectionHeader(title: String, modifier: Modifier = Modifier) {
+ Text(
+ text = title.uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/recipes/RecipesViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/recipes/RecipesViewModel.kt
new file mode 100644
index 0000000..9b83d8b
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/recipes/RecipesViewModel.kt
@@ -0,0 +1,163 @@
+package com.pantree.app.ui.recipes
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pantree.app.data.local.entity.RecipeCacheEntity
+import com.pantree.app.data.model.RecipeDetailDto
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.repository.RecipeRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+// ─── Filter ───────────────────────────────────────────────────────────────────
+
+enum class RecipeFilter(val apiValue: String?, val label: String) {
+ ALL(null, "All"),
+ CAN_MAKE("available", "Can make"),
+ PARTIAL("partial", "Partial")
+}
+
+// ─── UI State ─────────────────────────────────────────────────────────────────
+
+data class RecipesUiState(
+ val recipes: List = emptyList(),
+ val filteredRecipes: List = emptyList(),
+ val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val errorMessage: String? = null,
+ val selectedFilter: RecipeFilter = RecipeFilter.ALL,
+ val searchQuery: String = "",
+ // Detail state
+ val selectedRecipe: RecipeDetailDto? = null,
+ val isLoadingDetail: Boolean = false,
+ val detailError: String? = null,
+ val selectedScale: Int = 1
+)
+
+sealed class RecipesEvent {
+ data class NavigateToDetail(val recipeId: String) : RecipesEvent()
+ data class AddedToShoppingList(val listName: String) : RecipesEvent()
+}
+
+// ─── ViewModel ────────────────────────────────────────────────────────────────
+
+@HiltViewModel
+class RecipesViewModel @Inject constructor(
+ private val recipeRepository: RecipeRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(RecipesUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ init {
+ viewModelScope.launch {
+ recipeRepository.observeRecipes().collect { recipes ->
+ _uiState.update { state ->
+ state.copy(
+ recipes = recipes,
+ filteredRecipes = applyFilters(recipes, state.selectedFilter, state.searchQuery)
+ )
+ }
+ }
+ }
+ refresh()
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isRefreshing = true, errorMessage = null) }
+ when (val result = recipeRepository.refreshRecipes()) {
+ is NetworkResult.Error -> {
+ if (result.code != "NO_CONNECTION") {
+ _uiState.update { it.copy(errorMessage = result.message) }
+ }
+ }
+ else -> Unit
+ }
+ _uiState.update { it.copy(isRefreshing = false) }
+ }
+ }
+
+ fun setFilter(filter: RecipeFilter) {
+ _uiState.update { state ->
+ state.copy(
+ selectedFilter = filter,
+ filteredRecipes = applyFilters(state.recipes, filter, state.searchQuery)
+ )
+ }
+ }
+
+ fun setSearchQuery(query: String) {
+ _uiState.update { state ->
+ state.copy(
+ searchQuery = query,
+ filteredRecipes = applyFilters(state.recipes, state.selectedFilter, query)
+ )
+ }
+ }
+
+ fun loadRecipeDetail(recipeId: String, scale: Int = 1) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoadingDetail = true, detailError = null, selectedScale = scale) }
+ when (val result = recipeRepository.getRecipeDetail(recipeId, scale.takeIf { it > 1 })) {
+ is NetworkResult.Success -> {
+ _uiState.update {
+ it.copy(
+ isLoadingDetail = false,
+ selectedRecipe = result.data.recipe
+ )
+ }
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoadingDetail = false,
+ detailError = if (result.httpStatus == 404)
+ "This recipe couldn't be found."
+ else result.message
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun setScale(recipeId: String, scale: Int) {
+ if (scale == _uiState.value.selectedScale) return
+ loadRecipeDetail(recipeId, scale)
+ }
+
+ fun clearDetail() {
+ _uiState.update { it.copy(selectedRecipe = null, detailError = null, selectedScale = 1) }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+
+ private fun applyFilters(
+ recipes: List,
+ filter: RecipeFilter,
+ query: String
+ ): List {
+ var result = recipes
+
+ result = when (filter) {
+ RecipeFilter.ALL -> result
+ RecipeFilter.CAN_MAKE -> result.filter { it.availabilityStatus == "can_make" }
+ RecipeFilter.PARTIAL -> result.filter { it.availabilityStatus == "partial" }
+ }
+
+ if (query.isNotBlank()) {
+ result = result.filter { it.name.contains(query, ignoreCase = true) }
+ }
+
+ return result
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/pantree/app/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..87ded91
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/settings/SettingsScreen.kt
@@ -0,0 +1,330 @@
+package com.pantree.app.ui.settings
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.pantree.app.ui.components.*
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ uiState: SettingsUiState,
+ onSyncNow: () -> Unit,
+ onSignOut: () -> Unit,
+ onDeleteAccount: () -> Unit,
+ onShowDeleteConfirm: () -> Unit,
+ onHideDeleteConfirm: () -> Unit,
+ onClearError: () -> Unit,
+ onClearSnackbar: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(uiState.snackbarMessage) {
+ uiState.snackbarMessage?.let {
+ snackbarHostState.showSnackbar(it)
+ onClearSnackbar()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Settings", style = MaterialTheme.typography.headlineMedium) },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background
+ )
+ )
+ },
+ snackbarHost = { PantreeSnackbarHost(hostState = snackbarHostState) },
+ modifier = modifier
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ ) {
+ // ── Profile card ──────────────────────────────────────────────────
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Avatar
+ if (uiState.profilePicUrl != null) {
+ AsyncImage(
+ model = uiState.profilePicUrl,
+ contentDescription = "Profile picture",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ )
+ } else {
+ Surface(
+ modifier = Modifier.size(56.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ text = uiState.userName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column {
+ Text(
+ text = uiState.userName,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ text = uiState.userEmail,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+
+ // ── Sync section ──────────────────────────────────────────────────
+ SectionHeader(title = "Data")
+
+ SettingsRow(
+ icon = Icons.Default.Sync,
+ title = "Sync now",
+ subtitle = uiState.lastSyncTimestamp?.let { "Last synced: ${formatTimestamp(it)}" }
+ ?: "Never synced",
+ onClick = onSyncNow,
+ trailing = {
+ if (uiState.isSyncing) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+
+ // ── Account section ───────────────────────────────────────────────
+ SectionHeader(title = "Account", modifier = Modifier.padding(top = 8.dp))
+
+ SettingsRow(
+ icon = Icons.Default.Logout,
+ title = "Sign out",
+ subtitle = "You'll need to sign in again to access your data.",
+ onClick = onSignOut
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+
+ SettingsRow(
+ icon = Icons.Default.DeleteForever,
+ title = "Delete account",
+ subtitle = "Your account will be scheduled for deletion. You have 15 days to change your mind.",
+ onClick = onShowDeleteConfirm,
+ titleColor = MaterialTheme.colorScheme.error,
+ iconTint = MaterialTheme.colorScheme.error
+ )
+
+ // ── Error ─────────────────────────────────────────────────────────
+ if (uiState.errorMessage != null) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(Icons.Default.Error, null, tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(18.dp))
+ Text(
+ uiState.errorMessage,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = onClearError, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Close, "Dismiss", tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(16.dp))
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // App version
+ Text(
+ text = "Pantree v1.0",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ // Delete account confirmation
+ if (uiState.showDeleteConfirm) {
+ AlertDialog(
+ onDismissRequest = onHideDeleteConfirm,
+ icon = {
+ Icon(
+ Icons.Default.DeleteForever,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ },
+ title = { Text("Delete your account?") },
+ text = {
+ Text(
+ "Your account will be scheduled for deletion. You'll have 15 days to restore it " +
+ "by signing back in. After that, everything is gone for good.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ },
+ confirmButton = {
+ Button(
+ onClick = onDeleteAccount,
+ enabled = !uiState.isLoading,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onError
+ )
+ } else {
+ Text("Yes, delete it")
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onHideDeleteConfirm) {
+ Text("Keep my account")
+ }
+ }
+ )
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SETTINGS ROW
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun SettingsRow(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ title: String,
+ subtitle: String? = null,
+ onClick: () -> Unit,
+ titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface,
+ iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant,
+ trailing: @Composable (() -> Unit)? = null
+) {
+ Surface(
+ onClick = onClick,
+ color = MaterialTheme.colorScheme.surface,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = iconTint,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = titleColor
+ )
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ if (trailing != null) {
+ trailing()
+ } else {
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+private fun formatTimestamp(isoTimestamp: String): String {
+ return try {
+ val instant = Instant.parse(isoTimestamp)
+ val local = instant.atZone(ZoneId.systemDefault())
+ DateTimeFormatter.ofPattern("MMM d 'at' h:mm a").format(local)
+ } catch (e: Exception) {
+ isoTimestamp
+ }
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..cfc862e
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,118 @@
+package com.pantree.app.ui.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pantree.app.data.local.TokenManager
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.repository.AuthRepository
+import com.pantree.app.data.repository.SyncRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class SettingsUiState(
+ val userName: String = "",
+ val userEmail: String = "",
+ val profilePicUrl: String? = null,
+ val lastSyncTimestamp: String? = null,
+ val isSyncing: Boolean = false,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val snackbarMessage: String? = null,
+ val showDeleteConfirm: Boolean = false
+)
+
+sealed class SettingsEvent {
+ object SignedOut : SettingsEvent()
+ object AccountDeleted : SettingsEvent()
+}
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+ private val syncRepository: SyncRepository,
+ private val tokenManager: TokenManager
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(SettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ init {
+ loadUserInfo()
+ }
+
+ private fun loadUserInfo() {
+ _uiState.update {
+ it.copy(
+ userName = tokenManager.getUserName() ?: "",
+ userEmail = tokenManager.getUserEmail() ?: "",
+ profilePicUrl = tokenManager.getProfilePicUrl(),
+ lastSyncTimestamp = tokenManager.getLastSyncTimestamp()
+ )
+ }
+ }
+
+ fun syncNow() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isSyncing = true) }
+ when (val result = syncRepository.sync()) {
+ is NetworkResult.Success -> {
+ _uiState.update {
+ it.copy(
+ isSyncing = false,
+ lastSyncTimestamp = tokenManager.getLastSyncTimestamp(),
+ snackbarMessage = "All caught up!"
+ )
+ }
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isSyncing = false,
+ snackbarMessage = if (result.code == "NO_CONNECTION")
+ "Can't sync — no internet connection."
+ else "Sync failed. Please try again."
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun signOut() {
+ authRepository.signOut()
+ viewModelScope.launch { _events.emit(SettingsEvent.SignedOut) }
+ }
+
+ fun showDeleteConfirm() = _uiState.update { it.copy(showDeleteConfirm = true) }
+ fun hideDeleteConfirm() = _uiState.update { it.copy(showDeleteConfirm = false) }
+
+ fun deleteAccount() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, showDeleteConfirm = false) }
+ when (authRepository.deleteAccount()) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(SettingsEvent.AccountDeleted)
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = "Couldn't delete your account right now. Please try again."
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun clearError() = _uiState.update { it.copy(errorMessage = null) }
+ fun clearSnackbar() = _uiState.update { it.copy(snackbarMessage = null) }
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/shopping/ShoppingScreens.kt b/android/app/src/main/java/com/pantree/app/ui/shopping/ShoppingScreens.kt
new file mode 100644
index 0000000..ca2bfd7
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/shopping/ShoppingScreens.kt
@@ -0,0 +1,827 @@
+package com.pantree.app.ui.shopping
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+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.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.material3.rememberSwipeToDismissBoxState
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.pantree.app.data.local.entity.ShoppingListEntity
+import com.pantree.app.data.local.entity.ShoppingListItemEntity
+import com.pantree.app.ui.components.*
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SHOPPING LISTS SCREEN — the index of all lists
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ShoppingListsScreen(
+ uiState: ShoppingUiState,
+ onRefresh: () -> Unit,
+ onCreateList: (name: String) -> Unit,
+ onDeleteList: (id: String, name: String) -> Unit,
+ onListClick: (id: String, name: String) -> Unit,
+ onClearError: () -> Unit,
+ onClearSnackbar: () -> Unit,
+ isOffline: Boolean = false,
+ modifier: Modifier = Modifier
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+ var showCreateSheet by remember { mutableStateOf(false) }
+ var deleteTarget by remember { mutableStateOf(null) }
+
+ LaunchedEffect(uiState.snackbarMessage) {
+ uiState.snackbarMessage?.let {
+ snackbarHostState.showSnackbar(it)
+ onClearSnackbar()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ Column {
+ TopAppBar(
+ title = { Text("Shopping Lists", style = MaterialTheme.typography.headlineMedium) },
+ actions = {
+ IconButton(onClick = onRefresh) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background
+ )
+ )
+ if (isOffline) OfflineBanner()
+ if (uiState.isRefreshingLists) SyncingIndicator(isSyncing = true)
+ }
+ },
+ floatingActionButton = {
+ if (!isOffline) {
+ ExtendedFloatingActionButton(
+ onClick = { showCreateSheet = true },
+ icon = { Icon(Icons.Default.Add, contentDescription = null) },
+ text = { Text("New list") },
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ },
+ snackbarHost = { PantreeSnackbarHost(hostState = snackbarHostState) },
+ modifier = modifier
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Error banner
+ AnimatedVisibility(visible = uiState.listsError != null) {
+ uiState.listsError?.let { msg ->
+ ErrorBannerRow(message = msg, onDismiss = onClearError)
+ }
+ }
+
+ when {
+ uiState.isRefreshingLists && uiState.lists.isEmpty() -> {
+ LoadingState(message = "Loading your lists…")
+ }
+ uiState.lists.isEmpty() && !uiState.isRefreshingLists -> {
+ EmptyState(
+ icon = Icons.Default.ShoppingCart,
+ title = "No shopping lists yet",
+ subtitle = "Create a list to start planning your next grocery run.",
+ actionLabel = if (!isOffline) "Create your first list" else null,
+ onAction = if (!isOffline) ({ showCreateSheet = true }) else null
+ )
+ }
+ else -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ horizontal = 16.dp,
+ top = 8.dp,
+ bottom = 96.dp
+ ),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(uiState.lists, key = { it.id }) { list ->
+ ShoppingListCard(
+ list = list,
+ isOffline = isOffline,
+ onClick = { onListClick(list.id, list.listName) },
+ onDelete = { deleteTarget = list },
+ modifier = Modifier.animateItem()
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Create list sheet
+ if (showCreateSheet) {
+ CreateListSheet(
+ isLoading = uiState.isOperationLoading,
+ onCreate = { name ->
+ onCreateList(name)
+ showCreateSheet = false
+ },
+ onDismiss = { showCreateSheet = false }
+ )
+ }
+
+ // Delete confirmation
+ deleteTarget?.let { list ->
+ ConfirmDeleteDialog(
+ title = "Delete list?",
+ message = "\"${list.listName}\" and all its items will be permanently deleted.",
+ confirmLabel = "Delete",
+ onConfirm = {
+ onDeleteList(list.id, list.listName)
+ deleteTarget = null
+ },
+ onDismiss = { deleteTarget = null }
+ )
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SHOPPING LIST CARD
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ShoppingListCard(
+ list: ShoppingListEntity,
+ isOffline: Boolean,
+ onClick: () -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = { if (!isOffline) onDelete() }
+ ),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Progress indicator
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = RoundedCornerShape(12.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ if (list.itemCount > 0) {
+ CircularProgressIndicator(
+ progress = { list.checkedCount.toFloat() / list.itemCount.toFloat() },
+ modifier = Modifier.size(32.dp),
+ strokeWidth = 3.dp,
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ Text(
+ text = "${list.checkedCount}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ } else {
+ Icon(
+ Icons.Default.ShoppingCart,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = list.listName,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = if (list.itemCount == 0) "Empty list"
+ else "${list.checkedCount} of ${list.itemCount} checked",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SHOPPING LIST DETAIL SCREEN
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ShoppingListDetailScreen(
+ listId: String,
+ uiState: ShoppingUiState,
+ onBack: () -> Unit,
+ onAddItem: (name: String, quantity: Double, unit: String) -> Unit,
+ onToggleItem: (ShoppingListItemEntity) -> Unit,
+ onDeleteItem: (ShoppingListItemEntity) -> Unit,
+ onDeleteList: () -> Unit,
+ onClearError: () -> Unit,
+ onClearSnackbar: () -> Unit,
+ isOffline: Boolean = false,
+ modifier: Modifier = Modifier
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+ var showAddSheet by remember { mutableStateOf(false) }
+ var showDeleteListDialog by remember { mutableStateOf(false) }
+ var deleteItemTarget by remember { mutableStateOf(null) }
+
+ val uncheckedItems = remember(uiState.items) { uiState.items.filter { !it.checkedOff } }
+ val checkedItems = remember(uiState.items) { uiState.items.filter { it.checkedOff } }
+
+ LaunchedEffect(uiState.snackbarMessage) {
+ uiState.snackbarMessage?.let {
+ snackbarHostState.showSnackbar(it)
+ onClearSnackbar()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = uiState.currentListName.ifBlank { "Shopping List" },
+ style = MaterialTheme.typography.headlineSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ if (!isOffline) {
+ IconButton(onClick = { showDeleteListDialog = true }) {
+ Icon(
+ Icons.Default.DeleteForever,
+ contentDescription = "Delete list",
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background
+ )
+ )
+ },
+ floatingActionButton = {
+ if (!isOffline) {
+ ExtendedFloatingActionButton(
+ onClick = { showAddSheet = true },
+ icon = { Icon(Icons.Default.Add, contentDescription = null) },
+ text = { Text("Add item") },
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ },
+ snackbarHost = { PantreeSnackbarHost(hostState = snackbarHostState) },
+ modifier = modifier
+ ) { paddingValues ->
+ when {
+ uiState.isLoadingDetail && uiState.items.isEmpty() -> {
+ LoadingState(
+ message = "Loading list…",
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ uiState.detailError != null -> {
+ ErrorState(
+ message = uiState.detailError,
+ onRetry = null,
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ uiState.items.isEmpty() && !uiState.isLoadingDetail -> {
+ EmptyState(
+ icon = Icons.Default.PlaylistAdd,
+ title = "This list is empty",
+ subtitle = "Add items manually or pull in ingredients from a recipe.",
+ actionLabel = if (!isOffline) "Add first item" else null,
+ onAction = if (!isOffline) ({ showAddSheet = true }) else null,
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ else -> {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentPadding = PaddingValues(bottom = 96.dp)
+ ) {
+ // Unchecked items
+ if (uncheckedItems.isNotEmpty()) {
+ item {
+ SectionHeader(
+ title = "${uncheckedItems.size} item${if (uncheckedItems.size != 1) "s" else ""} to get"
+ )
+ }
+ items(uncheckedItems, key = { it.id }) { item ->
+ ShoppingItemRow(
+ item = item,
+ isOffline = isOffline,
+ onToggle = { onToggleItem(item) },
+ onDelete = { deleteItemTarget = item },
+ modifier = Modifier.animateItem()
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+
+ // Checked items
+ if (checkedItems.isNotEmpty()) {
+ item {
+ SectionHeader(
+ title = "${checkedItems.size} checked",
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ items(checkedItems, key = { it.id }) { item ->
+ ShoppingItemRow(
+ item = item,
+ isOffline = isOffline,
+ onToggle = { onToggleItem(item) },
+ onDelete = { deleteItemTarget = item },
+ modifier = Modifier.animateItem()
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Add item sheet
+ if (showAddSheet) {
+ AddShoppingItemSheet(
+ isLoading = uiState.isOperationLoading,
+ onAdd = { name, qty, unit ->
+ onAddItem(name, qty, unit)
+ showAddSheet = false
+ },
+ onDismiss = { showAddSheet = false }
+ )
+ }
+
+ // Delete item confirmation
+ deleteItemTarget?.let { item ->
+ ConfirmDeleteDialog(
+ title = "Remove item?",
+ message = "\"${item.itemName}\" will be removed from this list.",
+ confirmLabel = "Remove",
+ onConfirm = {
+ onDeleteItem(item)
+ deleteItemTarget = null
+ },
+ onDismiss = { deleteItemTarget = null }
+ )
+ }
+
+ // Delete list confirmation
+ if (showDeleteListDialog) {
+ ConfirmDeleteDialog(
+ title = "Delete this list?",
+ message = "\"${uiState.currentListName}\" and all its items will be permanently deleted.",
+ confirmLabel = "Delete list",
+ onConfirm = {
+ onDeleteList()
+ showDeleteListDialog = false
+ },
+ onDismiss = { showDeleteListDialog = false }
+ )
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SHOPPING ITEM ROW — swipe to delete, tap to toggle
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+private fun ShoppingItemRow(
+ item: ShoppingListItemEntity,
+ isOffline: Boolean,
+ onToggle: () -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val dismissState = rememberSwipeToDismissBoxState(
+ confirmValueChange = { value ->
+ if (value == SwipeToDismissBoxValue.EndToStart && !isOffline) {
+ onDelete()
+ true
+ } else false
+ }
+ )
+
+ SwipeToDismissBox(
+ state = dismissState,
+ enableDismissFromStartToEnd = false,
+ enableDismissFromEndToStart = !isOffline,
+ backgroundContent = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.errorContainer)
+ .padding(horizontal = 20.dp),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete",
+ tint = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ },
+ modifier = modifier
+ ) {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = { if (!isOffline) onToggle() },
+ onLongClick = { if (!isOffline) onDelete() }
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = item.checkedOff,
+ onCheckedChange = { if (!isOffline) onToggle() },
+ enabled = !isOffline
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.itemName,
+ style = MaterialTheme.typography.bodyLarge.copy(
+ textDecoration = if (item.checkedOff) TextDecoration.LineThrough else TextDecoration.None
+ ),
+ color = if (item.checkedOff)
+ MaterialTheme.colorScheme.onSurfaceVariant
+ else
+ MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = formatQuantity(item.quantity, item.unit),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// CREATE LIST SHEET
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CreateListSheet(
+ isLoading: Boolean,
+ onCreate: (name: String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+ var name by remember { mutableStateOf("") }
+ var nameError by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text("New shopping list", style = MaterialTheme.typography.headlineSmall)
+
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it; nameError = null },
+ label = { Text("List name") },
+ leadingIcon = { Icon(Icons.Default.ShoppingCart, contentDescription = null) },
+ isError = nameError != null,
+ supportingText = nameError?.let { { Text(it) } },
+ placeholder = { Text("e.g. Weekly Groceries") },
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.Words,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ if (name.isBlank()) nameError = "List name is required"
+ else onCreate(name.trim())
+ }
+ ),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f).height(52.dp)
+ ) { Text("Cancel") }
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ if (name.isBlank()) nameError = "List name is required"
+ else onCreate(name.trim())
+ },
+ enabled = !isLoading,
+ modifier = Modifier.weight(1f).height(52.dp)
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Create list")
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ADD SHOPPING ITEM SHEET
+// ─────────────────────────────────────────────────────────────────────────────
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AddShoppingItemSheet(
+ isLoading: Boolean,
+ onAdd: (name: String, quantity: Double, unit: String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+
+ var name by remember { mutableStateOf("") }
+ var quantityText by remember { mutableStateOf("1") }
+ var unit by remember { mutableStateOf("") }
+ var nameError by remember { mutableStateOf(null) }
+ var quantityError by remember { mutableStateOf(null) }
+ var unitError by remember { mutableStateOf(null) }
+
+ // Common unit suggestions
+ val unitSuggestions = listOf("cups", "tbsp", "tsp", "oz", "lbs", "g", "kg", "ml", "L", "whole", "pieces")
+
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+
+ fun validate(): Boolean {
+ var valid = true
+ nameError = if (name.isBlank()) { valid = false; "Item name is required" } else null
+ quantityError = when {
+ quantityText.isBlank() -> { valid = false; "Quantity is required" }
+ quantityText.toDoubleOrNull() == null -> { valid = false; "Enter a number" }
+ (quantityText.toDoubleOrNull() ?: 0.0) <= 0.0 -> { valid = false; "Must be greater than 0" }
+ else -> null
+ }
+ unitError = if (unit.isBlank()) { valid = false; "Unit is required (e.g. cups, oz, whole)" } else null
+ return valid
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text("Add item", style = MaterialTheme.typography.headlineSmall)
+
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it; nameError = null },
+ label = { Text("Item name") },
+ leadingIcon = { Icon(Icons.Default.LocalGroceryStore, contentDescription = null) },
+ isError = nameError != null,
+ supportingText = nameError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.Words,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) }),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedTextField(
+ value = quantityText,
+ onValueChange = { quantityText = it; quantityError = null },
+ label = { Text("Qty") },
+ isError = quantityError != null,
+ supportingText = quantityError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Decimal,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) }),
+ singleLine = true,
+ modifier = Modifier.weight(1f)
+ )
+
+ OutlinedTextField(
+ value = unit,
+ onValueChange = { unit = it; unitError = null },
+ label = { Text("Unit") },
+ isError = unitError != null,
+ supportingText = unitError?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ if (validate()) onAdd(name.trim(), quantityText.toDouble(), unit.trim())
+ }
+ ),
+ singleLine = true,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ // Unit quick-pick chips
+ androidx.compose.foundation.lazy.LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ items(unitSuggestions) { suggestion ->
+ SuggestionChip(
+ onClick = { unit = suggestion; unitError = null },
+ label = { Text(suggestion, style = MaterialTheme.typography.labelSmall) }
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f).height(52.dp)
+ ) { Text("Cancel") }
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ if (validate()) onAdd(name.trim(), quantityText.toDouble(), unit.trim())
+ },
+ enabled = !isLoading,
+ modifier = Modifier.weight(1f).height(52.dp)
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Add to list")
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SHARED HELPERS
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun ErrorBannerRow(message: String, onDismiss: () -> Unit) {
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(Icons.Default.Error, null, tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(18.dp))
+ Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.weight(1f))
+ IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Close, "Dismiss", tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(16.dp))
+ }
+ }
+ }
+}
+
+private fun formatQuantity(quantity: Double, unit: String): String {
+ val formatted = if (quantity == quantity.toLong().toDouble()) {
+ quantity.toLong().toString()
+ } else {
+ "%.2f".format(quantity).trimEnd('0').trimEnd('.')
+ }
+ return "$formatted $unit"
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/shopping/ShoppingViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/shopping/ShoppingViewModel.kt
new file mode 100644
index 0000000..4ddfc6e
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/shopping/ShoppingViewModel.kt
@@ -0,0 +1,229 @@
+package com.pantree.app.ui.shopping
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pantree.app.data.local.entity.ShoppingListEntity
+import com.pantree.app.data.local.entity.ShoppingListItemEntity
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.repository.ShoppingRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+// ─── UI State ─────────────────────────────────────────────────────────────────
+
+data class ShoppingUiState(
+ // Lists screen
+ val lists: List = emptyList(),
+ val isLoadingLists: Boolean = false,
+ val isRefreshingLists: Boolean = false,
+ val listsError: String? = null,
+ // Detail screen
+ val currentListId: String? = null,
+ val currentListName: String = "",
+ val items: List = emptyList(),
+ val isLoadingDetail: Boolean = false,
+ val detailError: String? = null,
+ // Shared
+ val isOperationLoading: Boolean = false,
+ val snackbarMessage: String? = null
+)
+
+sealed class ShoppingEvent {
+ data class ListCreated(val listId: String, val listName: String) : ShoppingEvent()
+ data class ListDeleted(val listName: String) : ShoppingEvent()
+ data class ItemAdded(val itemName: String, val merged: Boolean) : ShoppingEvent()
+ data class ItemDeleted(val itemName: String) : ShoppingEvent()
+ data class RecipesAdded(val count: Int) : ShoppingEvent()
+ data class Error(val message: String) : ShoppingEvent()
+}
+
+// ─── ViewModel ────────────────────────────────────────────────────────────────
+
+@HiltViewModel
+class ShoppingViewModel @Inject constructor(
+ private val shoppingRepository: ShoppingRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(ShoppingUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ init {
+ viewModelScope.launch {
+ shoppingRepository.observeShoppingLists().collect { lists ->
+ _uiState.update { it.copy(lists = lists) }
+ }
+ }
+ refreshLists()
+ }
+
+ fun refreshLists() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isRefreshingLists = true, listsError = null) }
+ when (val result = shoppingRepository.refreshShoppingLists()) {
+ is NetworkResult.Error -> {
+ if (result.code != "NO_CONNECTION") {
+ _uiState.update { it.copy(listsError = result.message) }
+ } else {
+ _uiState.update { it.copy(snackbarMessage = "Offline — showing saved data") }
+ }
+ }
+ else -> Unit
+ }
+ _uiState.update { it.copy(isRefreshingLists = false) }
+ }
+ }
+
+ fun loadListDetail(listId: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(currentListId = listId, isLoadingDetail = true, detailError = null) }
+
+ // Observe local items immediately
+ shoppingRepository.observeListItems(listId).collect { items ->
+ _uiState.update { it.copy(items = items) }
+ }
+ }
+
+ viewModelScope.launch {
+ when (val result = shoppingRepository.getListDetail(listId)) {
+ is NetworkResult.Success -> {
+ _uiState.update {
+ it.copy(
+ isLoadingDetail = false,
+ currentListName = result.data.shoppingList.listName
+ )
+ }
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoadingDetail = false,
+ detailError = if (result.httpStatus == 404)
+ "This list couldn't be found."
+ else result.message
+ )
+ }
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun createList(listName: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isOperationLoading = true) }
+ when (val result = shoppingRepository.createList(listName.trim())) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isOperationLoading = false) }
+ _events.emit(ShoppingEvent.ListCreated(
+ listId = result.data.shoppingList.id,
+ listName = result.data.shoppingList.listName
+ ))
+ }
+ is NetworkResult.Error -> {
+ _uiState.update { it.copy(isOperationLoading = false) }
+ _events.emit(ShoppingEvent.Error(result.message))
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun deleteList(listId: String, listName: String) {
+ viewModelScope.launch {
+ when (val result = shoppingRepository.deleteList(listId)) {
+ is NetworkResult.Success -> {
+ _events.emit(ShoppingEvent.ListDeleted(listName))
+ }
+ is NetworkResult.Error -> {
+ _events.emit(ShoppingEvent.Error(result.message))
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun addItem(listId: String, itemName: String, quantity: Double, unit: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isOperationLoading = true) }
+ when (val result = shoppingRepository.addItem(listId, itemName.trim(), quantity, unit.trim())) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isOperationLoading = false) }
+ _events.emit(ShoppingEvent.ItemAdded(
+ itemName = result.data.item.itemName,
+ merged = result.data.merged
+ ))
+ }
+ is NetworkResult.Error -> {
+ _uiState.update { it.copy(isOperationLoading = false) }
+ _events.emit(ShoppingEvent.Error(result.message))
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun addRecipesToList(listId: String, recipeIds: List, scale: Int = 1) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isOperationLoading = true) }
+ when (val result = shoppingRepository.addRecipesToList(listId, recipeIds, scale)) {
+ is NetworkResult.Success -> {
+ _uiState.update { it.copy(isOperationLoading = false) }
+ _events.emit(ShoppingEvent.RecipesAdded(result.data.recipesAdded))
+ }
+ is NetworkResult.Error -> {
+ _uiState.update { it.copy(isOperationLoading = false) }
+ _events.emit(ShoppingEvent.Error(result.message))
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun toggleItemChecked(listId: String, item: ShoppingListItemEntity) {
+ viewModelScope.launch {
+ shoppingRepository.updateItem(
+ listId = listId,
+ itemId = item.id,
+ checkedOff = !item.checkedOff
+ )
+ // Optimistic update — don't wait for server
+ }
+ }
+
+ fun updateItemQuantity(listId: String, itemId: String, quantity: Double, unit: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isOperationLoading = true) }
+ when (val result = shoppingRepository.updateItem(listId, itemId, quantity, unit)) {
+ is NetworkResult.Success -> _uiState.update { it.copy(isOperationLoading = false) }
+ is NetworkResult.Error -> {
+ _uiState.update { it.copy(isOperationLoading = false) }
+ _events.emit(ShoppingEvent.Error(result.message))
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun deleteItem(listId: String, item: ShoppingListItemEntity) {
+ viewModelScope.launch {
+ when (val result = shoppingRepository.deleteItem(listId, item.id)) {
+ is NetworkResult.Success -> {
+ _events.emit(ShoppingEvent.ItemDeleted(item.itemName))
+ }
+ is NetworkResult.Error -> {
+ _events.emit(ShoppingEvent.Error(result.message))
+ }
+ is NetworkResult.Loading -> Unit
+ }
+ }
+ }
+
+ fun clearListsError() = _uiState.update { it.copy(listsError = null) }
+ fun clearDetailError() = _uiState.update { it.copy(detailError = null) }
+ fun clearSnackbar() = _uiState.update { it.copy(snackbarMessage = null) }
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/theme/Color.kt b/android/app/src/main/java/com/pantree/app/ui/theme/Color.kt
new file mode 100644
index 0000000..1f0dd80
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/theme/Color.kt
@@ -0,0 +1,39 @@
+package com.pantree.app.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+// ─── Pantree Brand Palette ────────────────────────────────────────────────────
+// Warm, earthy, approachable. Not a sterile medical app. Not a finance dashboard.
+// Something that feels like a kitchen.
+
+val PantreeGreen = Color(0xFF3D7A5A)
+val PantreeGreenLight = Color(0xFF5A9E78)
+val PantreeGreenDark = Color(0xFF2A5740)
+
+val PantreeOrange = Color(0xFFE07B39)
+val PantreeOrangeLight = Color(0xFFEA9B62)
+val PantreeOrangeDark = Color(0xFFB85E22)
+
+val PantreeCream = Color(0xFFFAF6F0)
+val PantreeCreamDark = Color(0xFFF0E8DC)
+
+val PantreeBrown = Color(0xFF5C3D2E)
+val PantreeBrownLight = Color(0xFF8B6355)
+
+val PantreeRed = Color(0xFFD94F4F)
+val PantreeRedLight = Color(0xFFE87070)
+
+val PantreeGray100 = Color(0xFFF5F5F5)
+val PantreeGray200 = Color(0xFFEEEEEE)
+val PantreeGray400 = Color(0xFFBDBDBD)
+val PantreeGray600 = Color(0xFF757575)
+val PantreeGray800 = Color(0xFF424242)
+val PantreeGray900 = Color(0xFF212121)
+
+val White = Color(0xFFFFFFFF)
+val Black = Color(0xFF000000)
+
+// Availability status colors
+val CanMakeGreen = Color(0xFF4CAF50)
+val PartialYellow = Color(0xFFFFC107)
+val MissingRed = Color(0xFFF44336)
diff --git a/android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt b/android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt
new file mode 100644
index 0000000..637ec3f
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt
@@ -0,0 +1,73 @@
+package com.pantree.app.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.toArgb
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val LightColorScheme = lightColorScheme(
+ primary = PantreeGreen,
+ onPrimary = White,
+ primaryContainer = PantreeGreenLight,
+ onPrimaryContainer = PantreeGreenDark,
+ secondary = PantreeOrange,
+ onSecondary = White,
+ secondaryContainer = PantreeOrangeLight,
+ onSecondaryContainer = PantreeOrangeDark,
+ tertiary = PantreeBrown,
+ onTertiary = White,
+ background = PantreeCream,
+ onBackground = PantreeGray900,
+ surface = White,
+ onSurface = PantreeGray900,
+ surfaceVariant = PantreeCreamDark,
+ onSurfaceVariant = PantreeGray600,
+ error = PantreeRed,
+ onError = White,
+ outline = PantreeGray400
+)
+
+private val DarkColorScheme = darkColorScheme(
+ primary = PantreeGreenLight,
+ onPrimary = PantreeGreenDark,
+ primaryContainer = PantreeGreenDark,
+ onPrimaryContainer = PantreeGreenLight,
+ secondary = PantreeOrangeLight,
+ onSecondary = PantreeOrangeDark,
+ background = PantreeGray900,
+ onBackground = PantreeCream,
+ surface = PantreeGray800,
+ onSurface = PantreeCream,
+ surfaceVariant = PantreeGray800,
+ onSurfaceVariant = PantreeGray400,
+ error = PantreeRedLight,
+ onError = PantreeGray900,
+ outline = PantreeGray600
+)
+
+@Composable
+fun PantreeTheme(
+ 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 = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = PantreeTypography,
+ content = content
+ )
+}
diff --git a/android/app/src/main/java/com/pantree/app/ui/theme/Type.kt b/android/app/src/main/java/com/pantree/app/ui/theme/Type.kt
new file mode 100644
index 0000000..fa361da
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/ui/theme/Type.kt
@@ -0,0 +1,93 @@
+package com.pantree.app.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val PantreeTypography = Typography(
+ displayLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = (-0.5).sp
+ ),
+ displayMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 28.sp,
+ lineHeight = 36.sp
+ ),
+ headlineLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 24.sp,
+ lineHeight = 32.sp
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 20.sp,
+ lineHeight = 28.sp
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp,
+ lineHeight = 24.sp
+ ),
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp
+ ),
+ titleMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp
+ ),
+ bodySmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp
+ ),
+ labelLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ labelMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+)
diff --git a/android/app/src/main/java/com/pantree/app/util/ConnectivityObserver.kt b/android/app/src/main/java/com/pantree/app/util/ConnectivityObserver.kt
new file mode 100644
index 0000000..25923be
--- /dev/null
+++ b/android/app/src/main/java/com/pantree/app/util/ConnectivityObserver.kt
@@ -0,0 +1,63 @@
+package com.pantree.app.util
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Observes network connectivity and exposes it as a Flow.
+ * isOffline = true means no active internet connection.
+ * Used to show the offline banner and disable write operations.
+ */
+@Singleton
+class ConnectivityObserver @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val connectivityManager =
+ context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ val isOffline: Flow = callbackFlow {
+ val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ trySend(false) // connected
+ }
+
+ override fun onLost(network: Network) {
+ trySend(true) // disconnected
+ }
+
+ override fun onUnavailable() {
+ trySend(true)
+ }
+ }
+
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+
+ connectivityManager.registerNetworkCallback(request, callback)
+
+ // Emit initial state
+ val isCurrentlyOffline = !isCurrentlyConnected()
+ trySend(isCurrentlyOffline)
+
+ awaitClose {
+ connectivityManager.unregisterNetworkCallback(callback)
+ }
+ }.distinctUntilChanged()
+
+ private fun isCurrentlyConnected(): Boolean {
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+}
diff --git a/android/app/src/test/java/com/pantree/app/ui/auth/AuthViewModelTest.kt b/android/app/src/test/java/com/pantree/app/ui/auth/AuthViewModelTest.kt
new file mode 100644
index 0000000..ce1e593
--- /dev/null
+++ b/android/app/src/test/java/com/pantree/app/ui/auth/AuthViewModelTest.kt
@@ -0,0 +1,206 @@
+package com.pantree.app.ui.auth
+
+import com.pantree.app.data.model.AuthResponse
+import com.pantree.app.data.model.UserDto
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.repository.AuthRepository
+import io.mockk.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.*
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AuthViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private lateinit var authRepository: AuthRepository
+ private lateinit var viewModel: AuthViewModel
+
+ private val fakeUser = UserDto(
+ id = "user-123",
+ email = "feyre@nightcourt.com",
+ name = "Feyre Archeron",
+ profilePictureUrl = null,
+ emailVerified = true,
+ deletedAt = null,
+ createdAt = "2024-01-15T10:30:00Z"
+ )
+
+ private val fakeAuthResponse = AuthResponse(
+ user = fakeUser,
+ token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
+ expiresAt = "2024-01-16T10:30:00Z"
+ )
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ authRepository = mockk(relaxed = true)
+ viewModel = AuthViewModel(authRepository)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ // ── Signup ────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `signup success emits NavigateToHome`() = runTest {
+ coEvery {
+ authRepository.signup(any(), any(), any())
+ } returns NetworkResult.Success(fakeAuthResponse)
+
+ val events = mutableListOf()
+ val job = launch { viewModel.events.collect { events.add(it) } }
+
+ viewModel.signup("feyre@nightcourt.com", "password1", "Feyre Archeron")
+ advanceUntilIdle()
+
+ assertTrue(events.any { it is AuthEvent.NavigateToHome })
+ assertFalse(viewModel.uiState.value.isLoading)
+ assertNull(viewModel.uiState.value.errorMessage)
+
+ job.cancel()
+ }
+
+ @Test
+ fun `signup with duplicate email shows friendly error`() = runTest {
+ coEvery {
+ authRepository.signup(any(), any(), any())
+ } returns NetworkResult.Error(
+ code = "CONFLICT",
+ message = "Email already registered.",
+ httpStatus = 409
+ )
+
+ viewModel.signup("feyre@nightcourt.com", "password1", "Feyre Archeron")
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isLoading)
+ assertNotNull(state.errorMessage)
+ assertTrue(state.errorMessage!!.contains("already exists"))
+ }
+
+ // ── Signin ────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `signin success emits NavigateToHome`() = runTest {
+ coEvery {
+ authRepository.signin(any(), any())
+ } returns NetworkResult.Success(fakeAuthResponse)
+
+ val events = mutableListOf()
+ val job = launch { viewModel.events.collect { events.add(it) } }
+
+ viewModel.signin("feyre@nightcourt.com", "password1")
+ advanceUntilIdle()
+
+ assertTrue(events.any { it is AuthEvent.NavigateToHome })
+ job.cancel()
+ }
+
+ @Test
+ fun `signin with wrong credentials shows error`() = runTest {
+ coEvery {
+ authRepository.signin(any(), any())
+ } returns NetworkResult.Error(
+ code = "UNAUTHORIZED",
+ message = "Invalid email or password.",
+ httpStatus = 401
+ )
+
+ viewModel.signin("feyre@nightcourt.com", "wrongpassword")
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isLoading)
+ assertNotNull(state.errorMessage)
+ assertTrue(state.errorMessage!!.contains("don't match"))
+ }
+
+ @Test
+ fun `signin on pending-deletion account emits NavigateToRestore`() = runTest {
+ coEvery {
+ authRepository.signin(any(), any())
+ } returns NetworkResult.Error(
+ code = "ACCOUNT_PENDING_DELETION",
+ message = "Account is pending deletion.",
+ httpStatus = 403,
+ extra = com.pantree.app.data.model.ApiError(
+ error = "Account is pending deletion.",
+ code = "ACCOUNT_PENDING_DELETION",
+ timestamp = "2024-01-15T10:30:00Z",
+ deletionScheduledAt = "2024-01-30T10:30:00Z",
+ canRestore = true
+ )
+ )
+
+ val events = mutableListOf()
+ val job = launch { viewModel.events.collect { events.add(it) } }
+
+ viewModel.signin("feyre@nightcourt.com", "password1")
+ advanceUntilIdle()
+
+ assertTrue(events.any { it is AuthEvent.NavigateToRestore })
+ job.cancel()
+ }
+
+ // ── Password reset ────────────────────────────────────────────────────────
+
+ @Test
+ fun `requestPasswordReset always emits PasswordResetEmailSent on success`() = runTest {
+ coEvery {
+ authRepository.requestPasswordReset(any())
+ } returns NetworkResult.Success(
+ com.pantree.app.data.model.MessageResponse(
+ message = "If an account exists...",
+ timestamp = "2024-01-15T10:30:00Z"
+ )
+ )
+
+ val events = mutableListOf()
+ val job = launch { viewModel.events.collect { events.add(it) } }
+
+ viewModel.requestPasswordReset("feyre@nightcourt.com")
+ advanceUntilIdle()
+
+ assertTrue(events.any { it is AuthEvent.PasswordResetEmailSent })
+ job.cancel()
+ }
+
+ // ── clearError ────────────────────────────────────────────────────────────
+
+ @Test
+ fun `clearError removes error message from state`() = runTest {
+ coEvery {
+ authRepository.signin(any(), any())
+ } returns NetworkResult.Error("UNAUTHORIZED", "Invalid credentials.", 401)
+
+ viewModel.signin("x@x.com", "bad")
+ advanceUntilIdle()
+
+ assertNotNull(viewModel.uiState.value.errorMessage)
+
+ viewModel.clearError()
+ assertNull(viewModel.uiState.value.errorMessage)
+ }
+
+ // ── isLoggedIn ────────────────────────────────────────────────────────────
+
+ @Test
+ fun `isLoggedIn delegates to repository`() {
+ every { authRepository.isLoggedIn() } returns true
+ assertTrue(viewModel.isLoggedIn())
+
+ every { authRepository.isLoggedIn() } returns false
+ assertFalse(viewModel.isLoggedIn())
+ }
+}
diff --git a/android/app/src/test/java/com/pantree/app/ui/pantry/PantryViewModelTest.kt b/android/app/src/test/java/com/pantree/app/ui/pantry/PantryViewModelTest.kt
new file mode 100644
index 0000000..4959939
--- /dev/null
+++ b/android/app/src/test/java/com/pantree/app/ui/pantry/PantryViewModelTest.kt
@@ -0,0 +1,142 @@
+package com.pantree.app.ui.pantry
+
+import com.pantree.app.data.local.entity.PantryItemEntity
+import com.pantree.app.data.model.PantryItemDto
+import com.pantree.app.data.model.PantryItemResponse
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.repository.PantryRepository
+import io.mockk.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.*
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class PantryViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private lateinit var pantryRepository: PantryRepository
+ private lateinit var viewModel: PantryViewModel
+
+ private val fakeItems = listOf(
+ PantryItemEntity("id-1", "Flour", 5, "2024-01-15T10:30:00Z", "2024-01-14T08:00:00Z"),
+ PantryItemEntity("id-2", "Butter", 2, "2024-01-15T09:00:00Z", "2024-01-14T08:00:00Z")
+ )
+
+ private val fakeItemDto = PantryItemDto(
+ id = "id-3",
+ itemName = "Eggs",
+ quantity = 12,
+ lastModified = "2024-01-15T11:00:00Z",
+ createdAt = "2024-01-15T11:00:00Z"
+ )
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ pantryRepository = mockk(relaxed = true)
+ every { pantryRepository.observePantryItems() } returns flowOf(fakeItems)
+ coEvery { pantryRepository.refreshPantry() } returns NetworkResult.Success(
+ com.pantree.app.data.model.PantryListResponse(fakeItems.map {
+ PantryItemDto(it.id, it.itemName, it.quantity, it.lastModified, it.createdAt)
+ }, "2024-01-15T10:30:05Z")
+ )
+ viewModel = PantryViewModel(pantryRepository)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `initial state loads items from cache`() = runTest {
+ advanceUntilIdle()
+ assertEquals(fakeItems, viewModel.uiState.value.items)
+ }
+
+ @Test
+ fun `addItem success emits ItemAdded event`() = runTest {
+ coEvery {
+ pantryRepository.addItem("Eggs", 12)
+ } returns NetworkResult.Success(PantryItemResponse(fakeItemDto))
+
+ val events = mutableListOf()
+ val job = launch { viewModel.events.collect { events.add(it) } }
+
+ viewModel.addItem("Eggs", 12)
+ advanceUntilIdle()
+
+ assertTrue(events.any { it is PantryEvent.ItemAdded && it.itemName == "Eggs" })
+ assertFalse(viewModel.uiState.value.isLoading)
+ job.cancel()
+ }
+
+ @Test
+ fun `addItem duplicate sets duplicateConflict state`() = runTest {
+ coEvery {
+ pantryRepository.addItem("Flour", 3)
+ } returns NetworkResult.Error(
+ code = "DUPLICATE_ITEM",
+ message = "'Flour' already exists in your pantry.",
+ httpStatus = 409
+ )
+
+ viewModel.addItem("Flour", 3)
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isLoading)
+ assertNotNull(state.duplicateConflict)
+ assertEquals("Flour", state.duplicateConflict!!.attemptedName)
+ }
+
+ @Test
+ fun `deleteItem success emits ItemDeleted event`() = runTest {
+ val item = fakeItems[0]
+ coEvery { pantryRepository.deleteItem(item.id) } returns NetworkResult.Success(Unit)
+
+ val events = mutableListOf()
+ val job = launch { viewModel.events.collect { events.add(it) } }
+
+ viewModel.deleteItem(item)
+ advanceUntilIdle()
+
+ assertTrue(events.any { it is PantryEvent.ItemDeleted && it.itemName == item.itemName })
+ job.cancel()
+ }
+
+ @Test
+ fun `refresh on no connection shows snackbar not error`() = runTest {
+ coEvery { pantryRepository.refreshPantry() } returns NetworkResult.Error(
+ code = "NO_CONNECTION",
+ message = "No internet connection.",
+ httpStatus = 0
+ )
+
+ viewModel.refresh()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertNull(state.errorMessage)
+ assertNotNull(state.snackbarMessage)
+ }
+
+ @Test
+ fun `clearDuplicateConflict removes conflict from state`() = runTest {
+ coEvery {
+ pantryRepository.addItem(any(), any())
+ } returns NetworkResult.Error("DUPLICATE_ITEM", "Exists.", 409)
+
+ viewModel.addItem("Flour", 1)
+ advanceUntilIdle()
+ assertNotNull(viewModel.uiState.value.duplicateConflict)
+
+ viewModel.clearDuplicateConflict()
+ assertNull(viewModel.uiState.value.duplicateConflict)
+ }
+}
diff --git a/android/app/src/test/java/com/pantree/app/ui/recipes/RecipesViewModelTest.kt b/android/app/src/test/java/com/pantree/app/ui/recipes/RecipesViewModelTest.kt
new file mode 100644
index 0000000..7db9ba6
--- /dev/null
+++ b/android/app/src/test/java/com/pantree/app/ui/recipes/RecipesViewModelTest.kt
@@ -0,0 +1,145 @@
+package com.pantree.app.ui.recipes
+
+import com.pantree.app.data.local.entity.RecipeCacheEntity
+import com.pantree.app.data.model.*
+import com.pantree.app.data.remote.NetworkResult
+import com.pantree.app.data.repository.RecipeRepository
+import io.mockk.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.*
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class RecipesViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private lateinit var recipeRepository: RecipeRepository
+ private lateinit var viewModel: RecipesViewModel
+
+ private val fakeRecipes = listOf(
+ RecipeCacheEntity("r-1", "Pancakes", 4, 5, "can_make", 5, 5, "[]"),
+ RecipeCacheEntity("r-2", "Chocolate Cake", 8, 9, "partial", 6, 9, "[\"cocoa\",\"vanilla\"]"),
+ RecipeCacheEntity("r-3", "Omelette", 2, 3, "missing", 0, 3, "[\"eggs\",\"cheese\",\"butter\"]")
+ )
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ recipeRepository = mockk(relaxed = true)
+ every { recipeRepository.observeRecipes() } returns flowOf(fakeRecipes)
+ coEvery { recipeRepository.refreshRecipes(any()) } returns NetworkResult.Success(
+ RecipeListResponse(emptyList(), "2024-01-15T10:30:05Z")
+ )
+ viewModel = RecipesViewModel(recipeRepository)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `initial state shows all recipes`() = runTest {
+ advanceUntilIdle()
+ assertEquals(3, viewModel.uiState.value.filteredRecipes.size)
+ assertEquals(RecipeFilter.ALL, viewModel.uiState.value.selectedFilter)
+ }
+
+ @Test
+ fun `filter CAN_MAKE shows only can_make recipes`() = runTest {
+ advanceUntilIdle()
+ viewModel.setFilter(RecipeFilter.CAN_MAKE)
+
+ val filtered = viewModel.uiState.value.filteredRecipes
+ assertEquals(1, filtered.size)
+ assertEquals("Pancakes", filtered[0].name)
+ }
+
+ @Test
+ fun `filter PARTIAL shows only partial recipes`() = runTest {
+ advanceUntilIdle()
+ viewModel.setFilter(RecipeFilter.PARTIAL)
+
+ val filtered = viewModel.uiState.value.filteredRecipes
+ assertEquals(1, filtered.size)
+ assertEquals("Chocolate Cake", filtered[0].name)
+ }
+
+ @Test
+ fun `search filters by name case-insensitively`() = runTest {
+ advanceUntilIdle()
+ viewModel.setSearchQuery("pan")
+
+ val filtered = viewModel.uiState.value.filteredRecipes
+ assertEquals(1, filtered.size)
+ assertEquals("Pancakes", filtered[0].name)
+ }
+
+ @Test
+ fun `search with no results returns empty list`() = runTest {
+ advanceUntilIdle()
+ viewModel.setSearchQuery("xyzzy")
+
+ assertTrue(viewModel.uiState.value.filteredRecipes.isEmpty())
+ }
+
+ @Test
+ fun `loadRecipeDetail success sets selectedRecipe`() = runTest {
+ val fakeDetail = RecipeDetailDto(
+ id = "r-1",
+ name = "Pancakes",
+ servings = 4,
+ scaledServings = 4,
+ instructions = "Mix and cook.",
+ ingredients = listOf(
+ RecipeIngredientDto("i-1", "flour", 2.0, "cups", true),
+ RecipeIngredientDto("i-2", "milk", 1.5, "cups", true),
+ RecipeIngredientDto("i-3", "eggs", 2.0, "whole", false)
+ ),
+ availability = AvailabilitySummaryDto("partial", 2, 3)
+ )
+
+ coEvery {
+ recipeRepository.getRecipeDetail("r-1", null)
+ } returns NetworkResult.Success(RecipeDetailResponse(fakeDetail))
+
+ viewModel.loadRecipeDetail("r-1")
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isLoadingDetail)
+ assertNotNull(state.selectedRecipe)
+ assertEquals("Pancakes", state.selectedRecipe!!.name)
+ assertNull(state.detailError)
+ }
+
+ @Test
+ fun `loadRecipeDetail 404 sets friendly detailError`() = runTest {
+ coEvery {
+ recipeRepository.getRecipeDetail("bad-id", null)
+ } returns NetworkResult.Error("NOT_FOUND", "Recipe not found.", 404)
+
+ viewModel.loadRecipeDetail("bad-id")
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isLoadingDetail)
+ assertNull(state.selectedRecipe)
+ assertNotNull(state.detailError)
+ assertTrue(state.detailError!!.contains("couldn't be found"))
+ }
+
+ @Test
+ fun `clearDetail resets selectedRecipe and scale`() = runTest {
+ viewModel.clearDetail()
+ val state = viewModel.uiState.value
+ assertNull(state.selectedRecipe)
+ assertNull(state.detailError)
+ assertEquals(1, state.selectedScale)
+ }
+}