1 Commits

Author SHA1 Message Date
Azriel
b42497a368 feat(frontend): implement full Android UI — auth, pantry, recipes, shopping lists, settings, navigation
## Summary
Complete Jetpack Compose Android frontend for Pantree Phase 1 MVP.

### Architecture
- MVVM + Repository pattern with Hilt DI
- Room local cache with Flow-based observation
- Retrofit + OkHttp with JWT auth interceptor
- EncryptedSharedPreferences token storage
- ConnectivityObserver for offline detection

### Screens & ViewModels
- Auth: SignIn, SignUp, ForgotPassword, AccountRestore
- Pantry: list, add/edit/delete items, duplicate conflict handling
- Recipes: browse with filter chips (All/Can Make/Partial), search, detail with scale (1×/2×/3×)
- Shopping Lists: list index, detail with check-off, add items, swipe-to-delete
- Settings: profile card, sync now, sign out, delete account

### State coverage — every screen handles all four states
- Loading: CircularProgressIndicator with contextual message
- Error: ErrorState with retry, inline error banners with dismiss
- Empty: EmptyState with icon, title, subtitle, optional CTA
- Success: full content with pull-to-refresh

### Components (CommonComponents.kt)
- LoadingState, InlineLoading
- ErrorState (full-screen with retry)
- EmptyState (icon + title + subtitle + optional action)
- OfflineBanner (read-only mode indicator)
- SyncingIndicator (animated, non-blocking)
- PantreeSnackbarHost
- ConfirmDeleteDialog (human-readable copy)
- SectionHeader

### Data layer
- ApiModels.kt: all request/response DTOs
- NetworkResult<T>: sealed Success/Error/Loading wrapper
- safeApiCall: maps network exceptions to friendly errors
- Repositories: Auth, Pantry, Recipe, Shopping, Sync
- Room entities + DAOs for offline cache
- SyncRepository: full + delta sync with tombstone support

### Navigation
- Screen.kt: sealed class route definitions
- NavGraph.kt: PantreeNavHost (auth/main split) + MainScaffold (bottom nav)
- Bottom navigation: Pantry, Recipes, Lists, Settings

### Theme
- PantreeTheme: warm earthy palette (green primary, orange secondary)
- Light + dark color schemes
- Custom typography scale

### Tests
- AuthViewModelTest: signup, signin, duplicate, pending-deletion, password reset, clearError
- PantryViewModelTest: CRUD, duplicate conflict, offline snackbar vs error
- RecipesViewModelTest: filters, search, detail load, 404 handling, clearDetail
2026-05-10 05:09:35 +00:00
70 changed files with 7116 additions and 4139 deletions

View File

@@ -1,39 +0,0 @@
# ─────────────────────────────────────────────
# Pantree Backend — Environment Variables
# Copy to .env and fill in real values.
# NEVER commit .env to version control.
# ─────────────────────────────────────────────
# Server
PORT=3000
NODE_ENV=development
# PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=pantree
DB_USER=postgres
DB_PASSWORD=changeme
# JWT — use a long random secret in production
JWT_SECRET=change-this-to-a-long-random-secret
JWT_EXPIRES_IN=24h
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
# Email (SMTP / SendGrid / SES)
EMAIL_HOST=smtp.ethereal.email
EMAIL_PORT=587
EMAIL_USER=
EMAIL_PASS=
EMAIL_FROM=noreply@pantree.app
# App base URL (used in password-reset links)
APP_BASE_URL=https://pantree.app
# Account deletion window (days)
ACCOUNT_DELETION_DAYS=15
# Password reset token expiry (hours)
RESET_TOKEN_EXPIRY_HOURS=1

117
android/app/build.gradle Normal file
View File

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

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".PantreeApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Pantree"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Pantree">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Pantree.SplashScreen"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link for password reset -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="pantree.app"
android:pathPrefix="/reset-password" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

View File

@@ -0,0 +1,7 @@
package com.pantree.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class PantreeApplication : Application()

View File

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

View File

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

View File

@@ -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<List<PantryItemEntity>>
@Query("SELECT * FROM pantry_items ORDER BY item_name COLLATE NOCASE ASC")
suspend fun getAll(): List<PantryItemEntity>
@Query("SELECT * FROM pantry_items WHERE id = :id")
suspend fun getById(id: String): PantryItemEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<PantryItemEntity>)
@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<List<ShoppingListEntity>>
@Query("SELECT * FROM shopping_lists ORDER BY last_modified DESC")
suspend fun getAll(): List<ShoppingListEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(lists: List<ShoppingListEntity>)
@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<List<ShoppingListItemEntity>>
@Query("SELECT * FROM shopping_list_items WHERE shopping_list_id = :listId ORDER BY item_name COLLATE NOCASE ASC")
suspend fun getByListId(listId: String): List<ShoppingListItemEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<ShoppingListItemEntity>)
@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<List<RecipeCacheEntity>>
@Query("SELECT * FROM recipes_cache ORDER BY name COLLATE NOCASE ASC")
suspend fun getAll(): List<RecipeCacheEntity>
@Query("SELECT * FROM recipes_cache WHERE id = :id")
suspend fun getById(id: String): RecipeCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(recipes: List<RecipeCacheEntity>)
@Query("DELETE FROM recipes_cache")
suspend fun deleteAll()
}

View File

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

View File

@@ -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<PantryItemDto>,
@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<String>
)
data class RecipeListResponse(
val recipes: List<RecipeSummaryDto>,
@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<RecipeIngredientDto>,
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<ShoppingListSummaryDto>,
@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<ShoppingListItemDto>
)
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<String>,
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<SyncShoppingListDto>,
@SerializedName("server_timestamp") val serverTimestamp: String,
@SerializedName("full_sync") val fullSync: Boolean
)
data class SyncPantryDto(
val items: List<SyncPantryItemDto>
)
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<SyncShoppingItemDto>
)
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
)

View File

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

View File

@@ -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<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(
val code: String,
val message: String,
val httpStatus: Int,
val extra: ApiError? = null
) : NetworkResult<Nothing>()
object Loading : NetworkResult<Nothing>()
}
/**
* Executes a Retrofit suspend call and wraps the result in NetworkResult.
* Parses the error body into ApiError when available.
*/
suspend fun <T> safeApiCall(call: suspend () -> Response<T>): NetworkResult<T> {
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
)
}
}

View File

@@ -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<AuthResponse>
@POST("v1/auth/signin")
suspend fun signin(@Body request: SigninRequest): Response<AuthResponse>
@POST("v1/auth/google")
suspend fun googleAuth(@Body request: GoogleAuthRequest): Response<AuthResponse>
@POST("v1/auth/password-reset")
suspend fun requestPasswordReset(@Body request: PasswordResetRequest): Response<MessageResponse>
@PUT("v1/auth/password-reset")
suspend fun confirmPasswordReset(@Body request: PasswordResetConfirmRequest): Response<MessageResponse>
@DELETE("v1/auth/account")
suspend fun deleteAccount(): Response<Unit>
@POST("v1/auth/restore-account")
suspend fun restoreAccount(): Response<RestoreAccountResponse>
// ─── Pantry ──────────────────────────────────────────────────────────────
@GET("v1/pantry")
suspend fun getPantryItems(): Response<PantryListResponse>
@POST("v1/pantry")
suspend fun addPantryItem(@Body request: AddPantryItemRequest): Response<PantryItemResponse>
@PUT("v1/pantry/{item_id}")
suspend fun updatePantryItem(
@Path("item_id") itemId: String,
@Body request: UpdatePantryItemRequest
): Response<PantryItemResponse>
@DELETE("v1/pantry/{item_id}")
suspend fun deletePantryItem(@Path("item_id") itemId: String): Response<Unit>
// ─── Recipes ─────────────────────────────────────────────────────────────
@GET("v1/recipes")
suspend fun getRecipes(
@Query("filter") filter: String? = null,
@Query("scale") scale: Int? = null
): Response<RecipeListResponse>
@GET("v1/recipes/{recipe_id}")
suspend fun getRecipeDetail(
@Path("recipe_id") recipeId: String,
@Query("scale") scale: Int? = null
): Response<RecipeDetailResponse>
// ─── Shopping Lists ───────────────────────────────────────────────────────
@GET("v1/shopping-lists")
suspend fun getShoppingLists(): Response<ShoppingListsResponse>
@POST("v1/shopping-lists")
suspend fun createShoppingList(@Body request: CreateShoppingListRequest): Response<CreateShoppingListResponse>
@GET("v1/shopping-lists/{list_id}")
suspend fun getShoppingListDetail(@Path("list_id") listId: String): Response<ShoppingListDetailResponse>
@DELETE("v1/shopping-lists/{list_id}")
suspend fun deleteShoppingList(@Path("list_id") listId: String): Response<Unit>
@POST("v1/shopping-lists/{list_id}/items")
suspend fun addShoppingItem(
@Path("list_id") listId: String,
@Body request: AddShoppingItemRequest
): Response<AddShoppingItemResponse>
@POST("v1/shopping-lists/{list_id}/add-recipes")
suspend fun addRecipesToList(
@Path("list_id") listId: String,
@Body request: AddRecipesToListRequest
): Response<AddRecipesToListResponse>
@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<UpdateShoppingItemResponse>
@DELETE("v1/shopping-lists/{list_id}/items/{item_id}")
suspend fun deleteShoppingItem(
@Path("list_id") listId: String,
@Path("item_id") itemId: String
): Response<Unit>
// ─── Sync ─────────────────────────────────────────────────────────────────
@GET("v1/sync")
suspend fun sync(@Query("since") since: String? = null): Response<SyncResponse>
}

View File

@@ -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<AuthResponse> {
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<AuthResponse> {
val result = safeApiCall { api.signin(SigninRequest(email, password)) }
if (result is NetworkResult.Success) {
persistSession(result.data)
}
return result
}
suspend fun googleAuth(idToken: String): NetworkResult<AuthResponse> {
val result = safeApiCall { api.googleAuth(GoogleAuthRequest(idToken)) }
if (result is NetworkResult.Success) {
persistSession(result.data)
}
return result
}
suspend fun requestPasswordReset(email: String): NetworkResult<MessageResponse> =
safeApiCall { api.requestPasswordReset(PasswordResetRequest(email)) }
suspend fun confirmPasswordReset(token: String, newPassword: String): NetworkResult<MessageResponse> =
safeApiCall { api.confirmPasswordReset(PasswordResetConfirmRequest(token, newPassword)) }
suspend fun deleteAccount(): NetworkResult<Unit> {
val result = safeApiCall { api.deleteAccount() }
if (result is NetworkResult.Success) {
tokenManager.clearAll()
}
return result
}
suspend fun restoreAccount(): NetworkResult<RestoreAccountResponse> =
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
)
}
}

View File

@@ -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<List<PantryItemEntity>> = pantryDao.observeAll()
/** Fetch from server and refresh local cache. Returns error on failure. */
suspend fun refreshPantry(): NetworkResult<PantryListResponse> {
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<PantryItemResponse> {
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<PantryItemResponse> {
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<Unit> {
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
)
}

View File

@@ -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<List<RecipeCacheEntity>> = recipeCacheDao.observeAll()
suspend fun refreshRecipes(filter: String? = null): NetworkResult<RecipeListResponse> {
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<RecipeDetailResponse> =
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)
)
}

View File

@@ -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<List<ShoppingListEntity>> = shoppingListDao.observeAll()
fun observeListItems(listId: String): Flow<List<ShoppingListItemEntity>> =
shoppingListItemDao.observeByListId(listId)
suspend fun refreshShoppingLists(): NetworkResult<ShoppingListsResponse> {
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<ShoppingListDetailResponse> {
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<CreateShoppingListResponse> {
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<Unit> {
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<AddShoppingItemResponse> {
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<String>,
scale: Int = 1
): NetworkResult<AddRecipesToListResponse> {
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<UpdateShoppingItemResponse> {
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<Unit> {
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
)
}

View File

@@ -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<SyncResponse> {
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
}
}

View File

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

View File

@@ -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<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(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<String?>(null) }
var emailError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(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<String?>(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
}
}

View File

@@ -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<AuthUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<AuthEvent>()
val events: SharedFlow<AuthEvent> = _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." }
}
}

View File

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

View File

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

View File

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

View File

@@ -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<PantryItemEntity?>(null) }
var deleteTarget by remember { mutableStateOf<PantryItemEntity?>(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<PantryItemEntity>,
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<String?>(null) }
var quantityError by remember { mutableStateOf<String?>(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<String?>(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
)
}

View File

@@ -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<PantryItemEntity> = 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<PantryUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<PantryEvent>()
val events: SharedFlow<PantryEvent> = _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) }
}
}

View File

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

View File

@@ -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<RecipeCacheEntity> = emptyList(),
val filteredRecipes: List<RecipeCacheEntity> = 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<RecipesUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<RecipesEvent>()
val events: SharedFlow<RecipesEvent> = _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<RecipeCacheEntity>,
filter: RecipeFilter,
query: String
): List<RecipeCacheEntity> {
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
}
}

View File

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

View File

@@ -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<SettingsUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<SettingsEvent>()
val events: SharedFlow<SettingsEvent> = _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) }
}

View File

@@ -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<ShoppingListEntity?>(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<ShoppingListItemEntity?>(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<String?>(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<String?>(null) }
var quantityError by remember { mutableStateOf<String?>(null) }
var unitError by remember { mutableStateOf<String?>(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"
}

View File

@@ -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<ShoppingListEntity> = 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<ShoppingListItemEntity> = 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<ShoppingUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<ShoppingEvent>()
val events: SharedFlow<ShoppingEvent> = _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<String>, 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) }
}

View File

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

View File

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

View File

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

View File

@@ -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<Boolean>.
* 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<Boolean> = 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)
}
}

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
{
"name": "pantree-backend",
"version": "1.0.0",
"description": "Pantree backend API server",
"main": "src/main/index.js",
"scripts": {
"start": "node src/main/index.js",
"dev": "nodemon src/main/index.js",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --runInBand --forceExit --coverage"
},
"dependencies": {
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.7",
"pg": "^8.11.3",
"uuid": "^9.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"jest": "^29.7.0",
"supertest": "^6.3.3"
},
"jest": {
"testEnvironment": "node",
"testMatch": [
"**/tests/**/*.test.js"
],
"setupFilesAfterFramework": []
}
}

View File

@@ -1,38 +0,0 @@
'use strict';
require('dotenv').config();
module.exports = {
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
db: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'pantree',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
},
jwt: {
secret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID || '',
},
email: {
host: process.env.EMAIL_HOST || 'smtp.ethereal.email',
port: parseInt(process.env.EMAIL_PORT || '587', 10),
user: process.env.EMAIL_USER || '',
pass: process.env.EMAIL_PASS || '',
from: process.env.EMAIL_FROM || 'noreply@pantree.app',
},
appBaseUrl: process.env.APP_BASE_URL || 'https://pantree.app',
accountDeletionDays: parseInt(process.env.ACCOUNT_DELETION_DAYS || '15', 10),
resetTokenExpiryHours: parseInt(process.env.RESET_TOKEN_EXPIRY_HOURS || '1', 10),
};

View File

@@ -1,31 +0,0 @@
'use strict';
const knex = require('knex');
const config = require('../config');
let instance = null;
/**
* Returns the singleton Knex instance.
* In tests, call setDb() to inject a test database.
*/
function getDb() {
if (!instance) {
instance = knex({
client: 'pg',
connection: config.db,
pool: { min: 2, max: 10 },
});
}
return instance;
}
/**
* Replaces the singleton — used in tests to inject an in-memory or
* test-scoped database without touching the real connection.
*/
function setDb(db) {
instance = db;
}
module.exports = { getDb, setDb };

View File

@@ -1,199 +0,0 @@
-- Migration: 001_initial_schema.sql
-- Full schema for Pantree Phase 1 MVP
-- Run once against a fresh PostgreSQL 15+ database.
-- ─────────────────────────────────────────────
-- Extensions
-- ─────────────────────────────────────────────
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ─────────────────────────────────────────────
-- USERS
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NULL,
name VARCHAR(100) NOT NULL,
profile_picture_url TEXT NULL,
auth_provider VARCHAR(20) NOT NULL DEFAULT 'email',
google_id VARCHAR(255) NULL,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMPTZ NULL,
deletion_scheduled_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email
ON users (LOWER(email));
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_id
ON users (google_id)
WHERE google_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_deletion_scheduled
ON users (deletion_scheduled_at)
WHERE deletion_scheduled_at IS NOT NULL;
-- ─────────────────────────────────────────────
-- PANTRY ITEMS
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pantry_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_name VARCHAR(200) NOT NULL,
item_name_lower VARCHAR(200) GENERATED ALWAYS AS (LOWER(item_name)) STORED,
quantity INTEGER NOT NULL CHECK (quantity > 0),
last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pantry_user_item
ON pantry_items (user_id, item_name_lower);
CREATE INDEX IF NOT EXISTS idx_pantry_user_id
ON pantry_items (user_id);
CREATE INDEX IF NOT EXISTS idx_pantry_last_modified
ON pantry_items (user_id, last_modified);
-- ─────────────────────────────────────────────
-- RECIPES
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(300) NOT NULL,
instructions TEXT NOT NULL,
servings INTEGER NOT NULL CHECK (servings > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recipes_name
ON recipes (LOWER(name));
-- ─────────────────────────────────────────────
-- RECIPE INGREDIENTS
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS recipe_ingredients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
item_name VARCHAR(200) NOT NULL,
item_name_lower VARCHAR(200) GENERATED ALWAYS AS (LOWER(item_name)) STORED,
quantity DECIMAL(10,4) NOT NULL CHECK (quantity > 0),
unit VARCHAR(50) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe
ON recipe_ingredients (recipe_id);
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_name
ON recipe_ingredients (item_name_lower);
-- ─────────────────────────────────────────────
-- SHOPPING LISTS
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS shopping_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
list_name VARCHAR(200) NOT NULL,
last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shopping_lists_user
ON shopping_lists (user_id);
CREATE INDEX IF NOT EXISTS idx_shopping_lists_last_modified
ON shopping_lists (user_id, last_modified);
-- ─────────────────────────────────────────────
-- SHOPPING LIST ITEMS
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS shopping_list_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shopping_list_id UUID NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
item_name VARCHAR(200) NOT NULL,
item_name_lower VARCHAR(200) GENERATED ALWAYS AS (LOWER(item_name)) STORED,
quantity DECIMAL(10,4) NOT NULL CHECK (quantity > 0),
unit VARCHAR(50) NOT NULL,
checked_off BOOLEAN NOT NULL DEFAULT FALSE,
last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_items_merge
ON shopping_list_items (shopping_list_id, item_name_lower, unit);
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_list
ON shopping_list_items (shopping_list_id);
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_last_modified
ON shopping_list_items (shopping_list_id, last_modified);
-- ─────────────────────────────────────────────
-- PASSWORD RESET TOKENS
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_password_reset_token_hash
ON password_reset_tokens (token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_user
ON password_reset_tokens (user_id);
-- ─────────────────────────────────────────────
-- DELETED RECORDS (tombstones for sync)
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS deleted_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
record_type VARCHAR(50) NOT NULL,
record_id UUID NOT NULL,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_deleted_records_user
ON deleted_records (user_id, deleted_at);
-- ─────────────────────────────────────────────
-- updated_at TRIGGER
-- ─────────────────────────────────────────────
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_pantry_updated_at
BEFORE UPDATE ON pantry_items
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_recipes_updated_at
BEFORE UPDATE ON recipes
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_shopping_lists_updated_at
BEFORE UPDATE ON shopping_lists
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_shopping_list_items_updated_at
BEFORE UPDATE ON shopping_list_items
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

View File

@@ -1,282 +0,0 @@
-- Seed: 002_seed_recipes.sql
-- 50 pre-populated recipes for Pantree Phase 1.
-- Recipes are read-only in Phase 1 — users cannot create or modify them.
INSERT INTO recipes (id, name, instructions, servings) VALUES
('00000000-0000-0000-0000-000000000001', 'Classic Pancakes',
E'1. Whisk together flour, baking powder, salt, and sugar.\n2. In a separate bowl, mix milk, egg, and melted butter.\n3. Combine wet and dry ingredients until just mixed.\n4. Cook on a greased griddle over medium heat until bubbles form, then flip.\n5. Serve warm with maple syrup.',
4),
('00000000-0000-0000-0000-000000000002', 'Scrambled Eggs',
E'1. Crack eggs into a bowl, add milk, salt, and pepper. Whisk well.\n2. Melt butter in a non-stick pan over low heat.\n3. Pour in egg mixture and stir gently with a spatula.\n4. Remove from heat while still slightly wet — residual heat finishes cooking.\n5. Serve immediately.',
2),
('00000000-0000-0000-0000-000000000003', 'Spaghetti Bolognese',
E'1. Brown ground beef in a large pan over medium-high heat. Drain fat.\n2. Add diced onion and garlic; cook until softened.\n3. Stir in tomato paste, crushed tomatoes, oregano, salt, and pepper.\n4. Simmer for 20 minutes.\n5. Cook spaghetti per package instructions.\n6. Serve sauce over pasta with grated parmesan.',
4),
('00000000-0000-0000-0000-000000000004', 'Caesar Salad',
E'1. Tear romaine lettuce into bite-sized pieces.\n2. Whisk together olive oil, lemon juice, garlic, worcestershire sauce, and parmesan.\n3. Toss lettuce with dressing.\n4. Top with croutons and extra parmesan.\n5. Season with black pepper.',
2),
('00000000-0000-0000-0000-000000000005', 'Chicken Stir Fry',
E'1. Slice chicken breast into thin strips. Season with salt and pepper.\n2. Heat oil in a wok over high heat.\n3. Cook chicken until golden, about 5 minutes. Remove.\n4. Stir fry vegetables (bell pepper, broccoli, carrot) for 3 minutes.\n5. Return chicken, add soy sauce and sesame oil.\n6. Serve over steamed rice.',
3),
('00000000-0000-0000-0000-000000000006', 'Banana Bread',
E'1. Preheat oven to 350°F (175°C). Grease a loaf pan.\n2. Mash 3 ripe bananas in a bowl.\n3. Mix in melted butter, sugar, egg, and vanilla.\n4. Stir in flour, baking soda, and salt until just combined.\n5. Pour into pan and bake 60 minutes until a toothpick comes out clean.',
8),
('00000000-0000-0000-0000-000000000007', 'Tomato Soup',
E'1. Sauté diced onion and garlic in butter until soft.\n2. Add crushed tomatoes, chicken broth, sugar, salt, and pepper.\n3. Simmer 15 minutes.\n4. Blend until smooth with an immersion blender.\n5. Stir in heavy cream. Adjust seasoning.\n6. Serve with crusty bread.',
4),
('00000000-0000-0000-0000-000000000008', 'Grilled Cheese Sandwich',
E'1. Butter one side of each bread slice.\n2. Place one slice butter-side down in a pan over medium heat.\n3. Layer cheese slices on top.\n4. Place second slice butter-side up.\n5. Cook until golden, about 3 minutes per side.\n6. Slice diagonally and serve.',
1),
('00000000-0000-0000-0000-000000000009', 'Chocolate Chip Cookies',
E'1. Preheat oven to 375°F (190°C).\n2. Cream butter and sugars until fluffy.\n3. Beat in eggs and vanilla.\n4. Mix in flour, baking soda, and salt.\n5. Fold in chocolate chips.\n6. Drop spoonfuls onto baking sheet.\n7. Bake 911 minutes until edges are golden.',
24),
('00000000-0000-0000-0000-000000000010', 'Guacamole',
E'1. Halve and pit avocados. Scoop flesh into a bowl.\n2. Mash with a fork to desired consistency.\n3. Stir in lime juice, salt, diced onion, cilantro, and jalapeño.\n4. Taste and adjust seasoning.\n5. Serve immediately with tortilla chips.',
4),
('00000000-0000-0000-0000-000000000011', 'French Toast',
E'1. Whisk eggs, milk, cinnamon, and vanilla in a shallow bowl.\n2. Dip bread slices in egg mixture, coating both sides.\n3. Cook in buttered pan over medium heat until golden, about 2 minutes per side.\n4. Serve with powdered sugar and maple syrup.',
2),
('00000000-0000-0000-0000-000000000012', 'Chicken Soup',
E'1. Simmer chicken thighs in water with salt for 30 minutes. Remove and shred.\n2. Add diced carrots, celery, and onion to broth.\n3. Cook vegetables until tender, about 15 minutes.\n4. Return shredded chicken to pot.\n5. Add egg noodles and cook per package instructions.\n6. Season with salt, pepper, and parsley.',
6),
('00000000-0000-0000-0000-000000000013', 'Fried Rice',
E'1. Cook rice and let cool completely (day-old rice works best).\n2. Scramble eggs in a wok with a little oil. Remove.\n3. Stir fry diced onion, carrot, and peas in oil.\n4. Add rice and stir fry over high heat.\n5. Return eggs, add soy sauce and sesame oil.\n6. Toss and serve.',
3),
('00000000-0000-0000-0000-000000000014', 'Beef Tacos',
E'1. Brown ground beef with diced onion and garlic.\n2. Season with cumin, chili powder, salt, and pepper.\n3. Warm taco shells in oven.\n4. Fill shells with beef, shredded cheese, lettuce, and tomato.\n5. Top with sour cream and salsa.',
4),
('00000000-0000-0000-0000-000000000015', 'Oatmeal',
E'1. Bring water or milk to a boil in a saucepan.\n2. Stir in oats and reduce heat to medium.\n3. Cook, stirring occasionally, for 5 minutes.\n4. Remove from heat and let stand 2 minutes.\n5. Top with brown sugar, cinnamon, and fruit.',
2),
('00000000-0000-0000-0000-000000000016', 'Pasta Carbonara',
E'1. Cook spaghetti in salted boiling water until al dente.\n2. Fry diced pancetta until crispy.\n3. Whisk eggs, parmesan, and black pepper in a bowl.\n4. Drain pasta, reserving 1 cup pasta water.\n5. Off heat, toss pasta with pancetta, then egg mixture, adding pasta water to loosen.\n6. Serve immediately with extra parmesan.',
2),
('00000000-0000-0000-0000-000000000017', 'Vegetable Curry',
E'1. Sauté diced onion, garlic, and ginger in oil.\n2. Add curry powder, cumin, and turmeric; cook 1 minute.\n3. Add diced potato, cauliflower, and chickpeas.\n4. Pour in coconut milk and vegetable broth.\n5. Simmer 20 minutes until vegetables are tender.\n6. Serve over basmati rice.',
4),
('00000000-0000-0000-0000-000000000018', 'BLT Sandwich',
E'1. Cook bacon until crispy.\n2. Toast bread slices.\n3. Spread mayonnaise on both slices.\n4. Layer lettuce, tomato slices, and bacon.\n5. Season with salt and pepper.\n6. Close sandwich and slice in half.',
1),
('00000000-0000-0000-0000-000000000019', 'Lemon Garlic Shrimp',
E'1. Season shrimp with salt, pepper, and paprika.\n2. Melt butter in a pan over medium-high heat.\n3. Add minced garlic and cook 30 seconds.\n4. Add shrimp and cook 2 minutes per side.\n5. Squeeze lemon juice over shrimp.\n6. Garnish with parsley and serve over pasta or rice.',
2),
('00000000-0000-0000-0000-000000000020', 'Apple Pie',
E'1. Preheat oven to 425°F (220°C).\n2. Peel, core, and slice apples. Toss with sugar, cinnamon, and flour.\n3. Line pie dish with bottom crust.\n4. Fill with apple mixture and dot with butter.\n5. Cover with top crust, seal edges, and cut vents.\n6. Bake 4550 minutes until golden.',
8),
('00000000-0000-0000-0000-000000000021', 'Minestrone Soup',
E'1. Sauté onion, carrot, and celery in olive oil.\n2. Add garlic, diced tomatoes, and tomato paste.\n3. Pour in vegetable broth. Add kidney beans and zucchini.\n4. Simmer 20 minutes.\n5. Add small pasta and cook until tender.\n6. Season with salt, pepper, and basil.',
6),
('00000000-0000-0000-0000-000000000022', 'Quesadillas',
E'1. Place a flour tortilla in a dry pan over medium heat.\n2. Sprinkle shredded cheese on one half.\n3. Add diced chicken, peppers, and onion.\n4. Fold tortilla in half and cook until golden, about 2 minutes per side.\n5. Slice into wedges and serve with salsa and sour cream.',
2),
('00000000-0000-0000-0000-000000000023', 'Baked Salmon',
E'1. Preheat oven to 400°F (200°C).\n2. Place salmon fillets on a lined baking sheet.\n3. Brush with olive oil, lemon juice, garlic, and dill.\n4. Season with salt and pepper.\n5. Bake 1215 minutes until fish flakes easily.\n6. Serve with roasted vegetables.',
2),
('00000000-0000-0000-0000-000000000024', 'Hummus',
E'1. Drain and rinse chickpeas, reserving liquid.\n2. Blend chickpeas, tahini, lemon juice, garlic, and salt in a food processor.\n3. Add reserved liquid or water to reach desired consistency.\n4. Drizzle with olive oil and sprinkle with paprika.\n5. Serve with pita bread or vegetables.',
6),
('00000000-0000-0000-0000-000000000025', 'Beef Stew',
E'1. Brown beef chunks in oil in a Dutch oven. Remove.\n2. Sauté onion, carrot, and celery until softened.\n3. Add garlic, tomato paste, and flour; cook 1 minute.\n4. Return beef, add beef broth, potatoes, and thyme.\n5. Simmer covered for 1.5 hours until beef is tender.\n6. Season and serve.',
6),
('00000000-0000-0000-0000-000000000026', 'Smoothie Bowl',
E'1. Blend frozen banana, frozen berries, and milk until thick.\n2. Pour into a bowl.\n3. Top with granola, sliced fresh fruit, and honey.\n4. Add chia seeds and coconut flakes if desired.\n5. Serve immediately.',
1),
('00000000-0000-0000-0000-000000000027', 'Shakshuka',
E'1. Sauté diced onion and bell pepper in olive oil.\n2. Add garlic, cumin, paprika, and chili flakes; cook 1 minute.\n3. Pour in crushed tomatoes. Simmer 10 minutes.\n4. Make wells in the sauce and crack eggs into them.\n5. Cover and cook until whites are set but yolks are runny.\n6. Garnish with feta and parsley.',
2),
('00000000-0000-0000-0000-000000000028', 'Pesto Pasta',
E'1. Cook pasta in salted boiling water until al dente.\n2. Blend basil, pine nuts, parmesan, garlic, and olive oil into pesto.\n3. Drain pasta, reserving ½ cup pasta water.\n4. Toss pasta with pesto, adding pasta water to loosen.\n5. Season with salt and pepper.\n6. Serve with extra parmesan.',
3),
('00000000-0000-0000-0000-000000000029', 'Roast Chicken',
E'1. Preheat oven to 425°F (220°C).\n2. Pat chicken dry. Rub with butter, garlic, salt, pepper, and herbs.\n3. Stuff cavity with lemon halves and fresh thyme.\n4. Roast breast-side up for 1 hour 15 minutes.\n5. Rest 10 minutes before carving.\n6. Serve with pan juices.',
4),
('00000000-0000-0000-0000-000000000030', 'Chocolate Brownies',
E'1. Preheat oven to 350°F (175°C). Grease an 8x8 pan.\n2. Melt butter and chocolate together.\n3. Whisk in sugar, eggs, and vanilla.\n4. Fold in flour, cocoa powder, and salt.\n5. Pour into pan and bake 2530 minutes.\n6. Cool completely before cutting.',
16),
('00000000-0000-0000-0000-000000000031', 'Greek Salad',
E'1. Chop cucumber, tomatoes, and red onion.\n2. Add kalamata olives and cubed feta cheese.\n3. Drizzle with olive oil and red wine vinegar.\n4. Season with oregano, salt, and pepper.\n5. Toss gently and serve.',
2),
('00000000-0000-0000-0000-000000000032', 'Mashed Potatoes',
E'1. Peel and cube potatoes. Boil in salted water until tender, about 15 minutes.\n2. Drain and return to pot.\n3. Mash with butter and warm milk until smooth.\n4. Season generously with salt and pepper.\n5. Serve immediately.',
4),
('00000000-0000-0000-0000-000000000033', 'Lentil Soup',
E'1. Sauté diced onion, carrot, and celery in olive oil.\n2. Add garlic, cumin, and turmeric; cook 1 minute.\n3. Add rinsed red lentils and vegetable broth.\n4. Simmer 25 minutes until lentils are soft.\n5. Blend partially for a creamy texture.\n6. Season with lemon juice, salt, and pepper.',
4),
('00000000-0000-0000-0000-000000000034', 'Avocado Toast',
E'1. Toast bread slices until golden.\n2. Mash avocado with lemon juice, salt, and pepper.\n3. Spread avocado on toast.\n4. Top with red pepper flakes and everything bagel seasoning.\n5. Optional: add a poached egg on top.',
1),
('00000000-0000-0000-0000-000000000035', 'Chicken Tikka Masala',
E'1. Marinate chicken in yogurt, lemon juice, and spices for 1 hour.\n2. Grill or broil chicken until charred. Set aside.\n3. Sauté onion, garlic, and ginger in oil.\n4. Add tomato purée, cream, and spices. Simmer 10 minutes.\n5. Add chicken and simmer 10 more minutes.\n6. Serve over basmati rice with naan.',
4),
('00000000-0000-0000-0000-000000000036', 'Waffles',
E'1. Preheat waffle iron.\n2. Whisk flour, baking powder, sugar, and salt.\n3. Mix milk, eggs, and melted butter separately.\n4. Combine wet and dry ingredients until just mixed.\n5. Pour batter into waffle iron and cook until golden.\n6. Serve with butter and maple syrup.',
4),
('00000000-0000-0000-0000-000000000037', 'Tuna Salad',
E'1. Drain canned tuna and flake into a bowl.\n2. Add mayonnaise, diced celery, and red onion.\n3. Season with lemon juice, salt, and pepper.\n4. Mix well.\n5. Serve on bread, crackers, or lettuce cups.',
2),
('00000000-0000-0000-0000-000000000038', 'Mushroom Risotto',
E'1. Sauté diced onion in butter until soft.\n2. Add arborio rice and toast 1 minute.\n3. Add white wine and stir until absorbed.\n4. Add warm broth one ladle at a time, stirring constantly.\n5. Stir in sautéed mushrooms and parmesan.\n6. Season with salt, pepper, and fresh thyme.',
3),
('00000000-0000-0000-0000-000000000039', 'Blueberry Muffins',
E'1. Preheat oven to 375°F (190°C). Line muffin tin.\n2. Whisk flour, sugar, baking powder, and salt.\n3. Mix milk, egg, and melted butter.\n4. Combine wet and dry ingredients until just mixed.\n5. Fold in blueberries.\n6. Fill muffin cups ¾ full and bake 2025 minutes.',
12),
('00000000-0000-0000-0000-000000000040', 'Pad Thai',
E'1. Soak rice noodles in warm water 30 minutes. Drain.\n2. Stir fry shrimp or chicken in oil. Remove.\n3. Scramble eggs in the same pan.\n4. Add noodles, protein, bean sprouts, and green onions.\n5. Pour in pad thai sauce (fish sauce, tamarind, sugar).\n6. Toss and serve with lime, peanuts, and chili flakes.',
2),
('00000000-0000-0000-0000-000000000041', 'Corn Chowder',
E'1. Cook diced bacon until crispy. Remove.\n2. Sauté onion and celery in bacon fat.\n3. Add diced potato, corn kernels, and chicken broth.\n4. Simmer 15 minutes until potato is tender.\n5. Stir in heavy cream and season.\n6. Top with bacon and chives.',
4),
('00000000-0000-0000-0000-000000000042', 'Egg Fried Noodles',
E'1. Cook noodles per package instructions. Drain.\n2. Scramble eggs in a wok with oil. Remove.\n3. Stir fry garlic and green onion.\n4. Add noodles and toss over high heat.\n5. Return eggs, add soy sauce and sesame oil.\n6. Serve immediately.',
2),
('00000000-0000-0000-0000-000000000043', 'Caprese Salad',
E'1. Slice fresh mozzarella and tomatoes.\n2. Arrange alternating slices on a plate.\n3. Tuck fresh basil leaves between slices.\n4. Drizzle with olive oil and balsamic glaze.\n5. Season with salt and black pepper.',
2),
('00000000-0000-0000-0000-000000000044', 'Baked Mac and Cheese',
E'1. Preheat oven to 375°F (190°C).\n2. Cook macaroni until al dente. Drain.\n3. Make cheese sauce: melt butter, whisk in flour, add milk, stir in cheddar.\n4. Combine pasta and sauce. Pour into baking dish.\n5. Top with breadcrumbs and extra cheese.\n6. Bake 25 minutes until bubbly and golden.',
6),
('00000000-0000-0000-0000-000000000045', 'Chicken Fajitas',
E'1. Slice chicken breast into strips. Season with fajita spices.\n2. Cook chicken in a hot pan until cooked through. Remove.\n3. Sauté sliced bell peppers and onion until tender.\n4. Warm flour tortillas.\n5. Serve chicken and vegetables in tortillas.\n6. Top with sour cream, guacamole, and salsa.',
3),
('00000000-0000-0000-0000-000000000046', 'Overnight Oats',
E'1. Combine rolled oats, milk, and yogurt in a jar.\n2. Add chia seeds and honey. Stir well.\n3. Cover and refrigerate overnight.\n4. In the morning, top with fresh berries and nuts.\n5. Serve cold or warm briefly in microwave.',
1),
('00000000-0000-0000-0000-000000000047', 'Stuffed Bell Peppers',
E'1. Preheat oven to 375°F (190°C).\n2. Cut tops off peppers and remove seeds.\n3. Brown ground beef with onion and garlic.\n4. Mix with cooked rice, tomato sauce, and seasoning.\n5. Fill peppers with mixture. Top with cheese.\n6. Bake 3035 minutes until peppers are tender.',
4),
('00000000-0000-0000-0000-000000000048', 'Garlic Bread',
E'1. Preheat oven to 375°F (190°C).\n2. Mix softened butter with minced garlic, parsley, and salt.\n3. Slice baguette in half lengthwise.\n4. Spread garlic butter generously on cut sides.\n5. Bake 10 minutes, then broil 2 minutes until golden.\n6. Slice and serve immediately.',
4),
('00000000-0000-0000-0000-000000000049', 'Spinach and Feta Omelette',
E'1. Whisk eggs with salt and pepper.\n2. Melt butter in a non-stick pan over medium heat.\n3. Pour in eggs and cook until edges set.\n4. Add fresh spinach and crumbled feta to one half.\n5. Fold omelette in half and cook 1 more minute.\n6. Slide onto plate and serve.',
1),
('00000000-0000-0000-0000-000000000050', 'Chocolate Mousse',
E'1. Melt dark chocolate and let cool slightly.\n2. Whip heavy cream to soft peaks.\n3. Whisk egg yolks with sugar until pale.\n4. Fold chocolate into egg mixture.\n5. Fold in whipped cream gently.\n6. Chill 2 hours before serving.',
4);
-- ─────────────────────────────────────────────
-- RECIPE INGREDIENTS
-- ─────────────────────────────────────────────
INSERT INTO recipe_ingredients (recipe_id, item_name, quantity, unit) VALUES
-- Classic Pancakes
('00000000-0000-0000-0000-000000000001', 'flour', 2.0, 'cups'),
('00000000-0000-0000-0000-000000000001', 'baking powder', 2.0, 'tsp'),
('00000000-0000-0000-0000-000000000001', 'salt', 0.5, 'tsp'),
('00000000-0000-0000-0000-000000000001', 'sugar', 2.0, 'tbsp'),
('00000000-0000-0000-0000-000000000001', 'milk', 1.5, 'cups'),
('00000000-0000-0000-0000-000000000001', 'egg', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000001', 'butter', 2.0, 'tbsp'),
-- Scrambled Eggs
('00000000-0000-0000-0000-000000000002', 'eggs', 4.0, 'whole'),
('00000000-0000-0000-0000-000000000002', 'milk', 2.0, 'tbsp'),
('00000000-0000-0000-0000-000000000002', 'butter', 1.0, 'tbsp'),
-- Spaghetti Bolognese
('00000000-0000-0000-0000-000000000003', 'spaghetti', 400.0, 'g'),
('00000000-0000-0000-0000-000000000003', 'ground beef', 500.0, 'g'),
('00000000-0000-0000-0000-000000000003', 'onion', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000003', 'garlic', 3.0, 'whole'),
('00000000-0000-0000-0000-000000000003', 'crushed tomatoes', 400.0, 'g'),
('00000000-0000-0000-0000-000000000003', 'tomato paste', 2.0, 'tbsp'),
('00000000-0000-0000-0000-000000000003', 'parmesan', 50.0, 'g'),
-- Caesar Salad
('00000000-0000-0000-0000-000000000004', 'romaine lettuce', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000004', 'olive oil', 3.0, 'tbsp'),
('00000000-0000-0000-0000-000000000004', 'lemon', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000004', 'garlic', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000004', 'parmesan', 30.0, 'g'),
-- Chicken Stir Fry
('00000000-0000-0000-0000-000000000005', 'chicken breast', 400.0, 'g'),
('00000000-0000-0000-0000-000000000005', 'bell pepper', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000005', 'broccoli', 200.0, 'g'),
('00000000-0000-0000-0000-000000000005', 'soy sauce', 3.0, 'tbsp'),
('00000000-0000-0000-0000-000000000005', 'sesame oil', 1.0, 'tbsp'),
('00000000-0000-0000-0000-000000000005', 'rice', 2.0, 'cups'),
-- Banana Bread
('00000000-0000-0000-0000-000000000006', 'banana', 3.0, 'whole'),
('00000000-0000-0000-0000-000000000006', 'flour', 1.5, 'cups'),
('00000000-0000-0000-0000-000000000006', 'sugar', 0.75, 'cups'),
('00000000-0000-0000-0000-000000000006', 'butter', 0.3333, 'cups'),
('00000000-0000-0000-0000-000000000006', 'egg', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000006', 'baking soda', 1.0, 'tsp'),
-- Tomato Soup
('00000000-0000-0000-0000-000000000007', 'crushed tomatoes', 800.0, 'g'),
('00000000-0000-0000-0000-000000000007', 'onion', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000007', 'garlic', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000007', 'butter', 2.0, 'tbsp'),
('00000000-0000-0000-0000-000000000007', 'heavy cream', 0.5, 'cups'),
-- Grilled Cheese
('00000000-0000-0000-0000-000000000008', 'bread', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000008', 'cheddar cheese', 60.0, 'g'),
('00000000-0000-0000-0000-000000000008', 'butter', 1.0, 'tbsp'),
-- Chocolate Chip Cookies
('00000000-0000-0000-0000-000000000009', 'flour', 2.25, 'cups'),
('00000000-0000-0000-0000-000000000009', 'butter', 1.0, 'cups'),
('00000000-0000-0000-0000-000000000009', 'sugar', 0.75, 'cups'),
('00000000-0000-0000-0000-000000000009', 'brown sugar', 0.75, 'cups'),
('00000000-0000-0000-0000-000000000009', 'eggs', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000009', 'vanilla extract', 1.0, 'tsp'),
('00000000-0000-0000-0000-000000000009', 'baking soda', 1.0, 'tsp'),
('00000000-0000-0000-0000-000000000009', 'chocolate chips', 2.0, 'cups'),
-- Guacamole
('00000000-0000-0000-0000-000000000010', 'avocado', 3.0, 'whole'),
('00000000-0000-0000-0000-000000000010', 'lime', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000010', 'onion', 0.5, 'whole'),
('00000000-0000-0000-0000-000000000010', 'cilantro', 2.0, 'tbsp'),
-- French Toast
('00000000-0000-0000-0000-000000000011', 'bread', 4.0, 'whole'),
('00000000-0000-0000-0000-000000000011', 'eggs', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000011', 'milk', 0.25, 'cups'),
('00000000-0000-0000-0000-000000000011', 'butter', 1.0, 'tbsp'),
('00000000-0000-0000-0000-000000000011', 'cinnamon', 0.5, 'tsp'),
-- Chicken Soup
('00000000-0000-0000-0000-000000000012', 'chicken thighs', 600.0, 'g'),
('00000000-0000-0000-0000-000000000012', 'carrot', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000012', 'celery', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000012', 'onion', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000012', 'egg noodles', 200.0, 'g'),
-- Fried Rice
('00000000-0000-0000-0000-000000000013', 'rice', 2.0, 'cups'),
('00000000-0000-0000-0000-000000000013', 'eggs', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000013', 'soy sauce', 3.0, 'tbsp'),
('00000000-0000-0000-0000-000000000013', 'sesame oil', 1.0, 'tbsp'),
('00000000-0000-0000-0000-000000000013', 'onion', 1.0, 'whole'),
-- Beef Tacos
('00000000-0000-0000-0000-000000000014', 'ground beef', 400.0, 'g'),
('00000000-0000-0000-0000-000000000014', 'taco shells', 8.0, 'whole'),
('00000000-0000-0000-0000-000000000014', 'cheddar cheese', 100.0, 'g'),
('00000000-0000-0000-0000-000000000014', 'onion', 1.0, 'whole'),
-- Oatmeal
('00000000-0000-0000-0000-000000000015', 'rolled oats', 1.0, 'cups'),
('00000000-0000-0000-0000-000000000015', 'milk', 2.0, 'cups'),
('00000000-0000-0000-0000-000000000015', 'brown sugar', 2.0, 'tbsp'),
('00000000-0000-0000-0000-000000000015', 'cinnamon', 0.5, 'tsp'),
-- Pasta Carbonara
('00000000-0000-0000-0000-000000000016', 'spaghetti', 200.0, 'g'),
('00000000-0000-0000-0000-000000000016', 'eggs', 3.0, 'whole'),
('00000000-0000-0000-0000-000000000016', 'parmesan', 80.0, 'g'),
('00000000-0000-0000-0000-000000000016', 'pancetta', 150.0, 'g'),
-- Vegetable Curry
('00000000-0000-0000-0000-000000000017', 'potato', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000017', 'cauliflower', 0.5, 'whole'),
('00000000-0000-0000-0000-000000000017', 'chickpeas', 400.0, 'g'),
('00000000-0000-0000-0000-000000000017', 'coconut milk', 400.0, 'ml'),
('00000000-0000-0000-0000-000000000017', 'onion', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000017', 'garlic', 3.0, 'whole'),
('00000000-0000-0000-0000-000000000017', 'rice', 2.0, 'cups'),
-- BLT Sandwich
('00000000-0000-0000-0000-000000000018', 'bread', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000018', 'bacon', 4.0, 'whole'),
('00000000-0000-0000-0000-000000000018', 'lettuce', 2.0, 'whole'),
('00000000-0000-0000-0000-000000000018', 'tomato', 1.0, 'whole'),
('00000000-0000-0000-0000-000000000018', 'mayonnaise', 2.0, 'tbsp'),
-- Lemon Garlic Shrimp
('00000000-0000-0000-0000-000000000019', 'shrimp', 400.0, 'g'),
('00000000-0000-0000-0000-000000000019', 'butter', 3.0, 'tbsp'),
('00000000-0000-0000-0000-000000000019', 'garlic', 4.0, 'whole'),
('00000000-0000-0000-0000-000000000019', 'lemon', 1.0, 'whole'),
-- Apple Pie
('00000000-0000-0000-0000-000000000020', 'apple', 6.0, 'whole'),
('00000000-0000-0000-0000-000000000020', 'flour', 2.5, 'cups'),
('00000000-0000-0000-0000-000000000020', 'butter', 1.0, 'cups'),
('00000000-0000-0000-0000-000000000020', 'sugar', 0.75, 'cups'),
('00000000-0000-0000-0000-000000000020', 'cinnamon', 1.0, 'tsp');

View File

@@ -1,36 +0,0 @@
'use strict';
const cron = require('node-cron');
const { getDb } = require('../db/knex');
/**
* Hard-deletes all user accounts whose deletion_scheduled_at has passed.
* CASCADE constraints handle pantry_items, shopping_lists, and all child records.
* Runs daily at 02:00 UTC.
*/
async function hardDeleteExpiredAccounts() {
const db = getDb();
const now = new Date();
try {
const deleted = await db('users')
.whereNotNull('deletion_scheduled_at')
.where('deletion_scheduled_at', '<=', now)
.delete()
.returning('id');
console.log(
`[CRON] Hard-deleted ${deleted.length} expired account(s) at ${now.toISOString()}`
);
} catch (err) {
console.error('[CRON] Error during account hard-delete job:', err);
}
}
function startCronJobs() {
// Daily at 02:00 UTC
cron.schedule('0 2 * * *', hardDeleteExpiredAccounts, { timezone: 'UTC' });
console.log('[CRON] Account cleanup job scheduled (daily 02:00 UTC)');
}
module.exports = { startCronJobs, hardDeleteExpiredAccounts };

View File

@@ -1,41 +0,0 @@
'use strict';
const express = require('express');
const cors = require('cors');
const { requestLogger } = require('../middleware/requestLogger');
const { errorHandler } = require('../middleware/errorHandler');
const authRoutes = require('../routes/auth');
const pantryRoutes = require('../routes/pantry');
const recipeRoutes = require('../routes/recipes');
const shoppingRoutes = require('../routes/shopping');
const syncRoutes = require('../routes/sync');
const app = express();
app.use(cors());
app.use(express.json());
app.use(requestLogger);
// Health check — no auth required
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.use('/v1/auth', authRoutes);
app.use('/v1/pantry', pantryRoutes);
app.use('/v1/recipes', recipeRoutes);
app.use('/v1/shopping-lists', shoppingRoutes);
app.use('/v1/sync', syncRoutes);
// 404 handler for unmatched routes
app.use((req, res) => {
res.status(404).json({
error: 'Route not found.',
code: 'NOT_FOUND',
timestamp: new Date().toISOString(),
});
});
app.use(errorHandler);
module.exports = app;

View File

@@ -1,15 +0,0 @@
'use strict';
require('dotenv').config();
const app = require('./app');
const { startCronJobs } = require('../jobs/cleanup');
const config = require('../config');
const PORT = config.port;
app.listen(PORT, () => {
console.log(`[SERVER] Pantree API listening on port ${PORT} (${config.nodeEnv})`);
startCronJobs();
});
module.exports = app;

View File

@@ -1,40 +0,0 @@
'use strict';
const jwt = require('jsonwebtoken');
const config = require('../config');
const { AppError } = require('../utils/errors');
/**
* JWT authentication middleware.
* Validates the Bearer token on every protected route.
* Attaches decoded payload to req.user.
* The token is not trusted until verified — no exceptions.
*/
function authenticate(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next(new AppError(401, 'UNAUTHORIZED', 'Missing or malformed Authorization header.'));
}
const token = authHeader.slice(7).trim();
if (!token) {
return next(new AppError(401, 'UNAUTHORIZED', 'Missing token.'));
}
let payload;
try {
payload = jwt.verify(token, config.jwt.secret);
} catch (err) {
return next(new AppError(401, 'UNAUTHORIZED', 'Invalid or expired token.'));
}
if (!payload.sub) {
return next(new AppError(401, 'UNAUTHORIZED', 'Malformed token payload.'));
}
req.user = payload;
return next();
}
module.exports = { authenticate };

View File

@@ -1,42 +0,0 @@
'use strict';
/**
* Central error handler.
* All errors thrown in route handlers or services arrive here.
* Known AppErrors are mapped to their HTTP status.
* Everything else is a 500.
*/
function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars
const timestamp = new Date().toISOString();
// Structured application error
if (err.isAppError) {
return res.status(err.status).json({
error: err.message,
code: err.code,
...(err.extra || {}),
timestamp,
});
}
// Zod validation error (surfaced via validate() helper)
if (err.zodError) {
return res.status(400).json({
error: err.message || 'Validation failed.',
code: 'VALIDATION_ERROR',
details: err.issues,
timestamp,
});
}
// Unexpected error — log it, return generic 500
console.error(`[ERROR] [${req.requestId || '-'}] ${err.stack || err.message}`);
return res.status(500).json({
error: 'An unexpected error occurred.',
code: 'INTERNAL_ERROR',
timestamp,
});
}
module.exports = { errorHandler };

View File

@@ -1,16 +0,0 @@
'use strict';
const { v4: uuidv4 } = require('uuid');
/**
* Attaches a unique request ID to every inbound request.
* Logs method + path for basic observability.
*/
function requestLogger(req, res, next) {
req.requestId = uuidv4();
res.setHeader('X-Request-Id', req.requestId);
console.log(`[${new Date().toISOString()}] [${req.requestId}] ${req.method} ${req.path}`);
next();
}
module.exports = { requestLogger };

View File

@@ -1,103 +0,0 @@
'use strict';
const express = require('express');
const router = express.Router();
const authService = require('../services/authService');
const { authenticate } = require('../middleware/authenticate');
const { validate } = require('../utils/validate');
const {
signupSchema,
signinSchema,
googleAuthSchema,
passwordResetRequestSchema,
passwordResetConfirmSchema,
restoreAccountSchema,
} = require('../utils/validation');
// POST /v1/auth/signup
router.post('/signup', async (req, res, next) => {
try {
const data = validate(signupSchema, req.body);
const result = await authService.signup(data);
return res.status(201).json(result);
} catch (err) {
return next(err);
}
});
// POST /v1/auth/signin
router.post('/signin', async (req, res, next) => {
try {
const data = validate(signinSchema, req.body);
const result = await authService.signin(data);
return res.status(200).json(result);
} catch (err) {
return next(err);
}
});
// POST /v1/auth/google
router.post('/google', async (req, res, next) => {
try {
const data = validate(googleAuthSchema, req.body);
const result = await authService.googleAuth(data);
const status = result.is_new_user ? 201 : 200;
// Strip internal flag from response
const { is_new_user, ...response } = result; // eslint-disable-line no-unused-vars
return res.status(status).json(response);
} catch (err) {
return next(err);
}
});
// POST /v1/auth/password-reset — request reset email
router.post('/password-reset', async (req, res, next) => {
try {
const data = validate(passwordResetRequestSchema, req.body);
await authService.requestPasswordReset(data);
// Always 200 — never reveal whether email exists
return res.status(200).json({
message: 'If an account exists with this email, a reset link has been sent.',
timestamp: new Date().toISOString(),
});
} catch (err) {
return next(err);
}
});
// PUT /v1/auth/password-reset — complete reset with token
router.put('/password-reset', async (req, res, next) => {
try {
const data = validate(passwordResetConfirmSchema, req.body);
await authService.confirmPasswordReset(data);
return res.status(200).json({
message: 'Password updated successfully.',
timestamp: new Date().toISOString(),
});
} catch (err) {
return next(err);
}
});
// DELETE /v1/auth/account — soft-delete authenticated user's account
router.delete('/account', authenticate, async (req, res, next) => {
try {
await authService.deleteAccount(req.user.sub);
return res.status(204).send();
} catch (err) {
return next(err);
}
});
// POST /v1/auth/restore-account — restore soft-deleted account
router.post('/restore-account', async (req, res, next) => {
try {
const data = validate(restoreAccountSchema, req.body);
const result = await authService.restoreAccount(data);
return res.status(200).json(result);
} catch (err) {
return next(err);
}
});
module.exports = router;

View File

@@ -1,58 +0,0 @@
'use strict';
const express = require('express');
const router = express.Router();
const pantryService = require('../services/pantryService');
const { authenticate } = require('../middleware/authenticate');
const { validate } = require('../utils/validate');
const { addPantryItemSchema, updatePantryItemSchema } = require('../utils/validation');
// All pantry routes require authentication
router.use(authenticate);
// GET /v1/pantry
router.get('/', async (req, res, next) => {
try {
const items = await pantryService.getPantryItems(req.user.sub);
return res.status(200).json({
items,
synced_at: new Date().toISOString(),
});
} catch (err) {
return next(err);
}
});
// POST /v1/pantry
router.post('/', async (req, res, next) => {
try {
const data = validate(addPantryItemSchema, req.body);
const item = await pantryService.addPantryItem(req.user.sub, data);
return res.status(201).json({ item });
} catch (err) {
return next(err);
}
});
// PUT /v1/pantry/:item_id
router.put('/:item_id', async (req, res, next) => {
try {
const data = validate(updatePantryItemSchema, req.body);
const item = await pantryService.updatePantryItem(req.user.sub, req.params.item_id, data);
return res.status(200).json({ item });
} catch (err) {
return next(err);
}
});
// DELETE /v1/pantry/:item_id
router.delete('/:item_id', async (req, res, next) => {
try {
await pantryService.deletePantryItem(req.user.sub, req.params.item_id);
return res.status(204).send();
} catch (err) {
return next(err);
}
});
module.exports = router;

View File

@@ -1,42 +0,0 @@
'use strict';
const express = require('express');
const router = express.Router();
const recipeService = require('../services/recipeService');
const { authenticate } = require('../middleware/authenticate');
const { validate } = require('../utils/validate');
const { recipeQuerySchema } = require('../utils/validation');
// All recipe routes require authentication
router.use(authenticate);
// GET /v1/recipes?filter=all|available|partial&scale=1|2|3
router.get('/', async (req, res, next) => {
try {
const query = validate(recipeQuerySchema, req.query);
const recipes = await recipeService.getRecipes(req.user.sub, query);
return res.status(200).json({
recipes,
synced_at: new Date().toISOString(),
});
} catch (err) {
return next(err);
}
});
// GET /v1/recipes/:recipe_id?scale=1|2|3
router.get('/:recipe_id', async (req, res, next) => {
try {
const query = validate(recipeQuerySchema, req.query);
const recipe = await recipeService.getRecipeById(
req.user.sub,
req.params.recipe_id,
query
);
return res.status(200).json({ recipe });
} catch (err) {
return next(err);
}
});
module.exports = router;

View File

@@ -1,128 +0,0 @@
'use strict';
const express = require('express');
const router = express.Router();
const shoppingService = require('../services/shoppingService');
const { authenticate } = require('../middleware/authenticate');
const { validate } = require('../utils/validate');
const {
createShoppingListSchema,
addShoppingListItemSchema,
updateShoppingListItemSchema,
addRecipesToListSchema,
} = require('../utils/validation');
// All shopping list routes require authentication
router.use(authenticate);
// GET /v1/shopping-lists
router.get('/', async (req, res, next) => {
try {
const lists = await shoppingService.getShoppingLists(req.user.sub);
return res.status(200).json({
shopping_lists: lists,
synced_at: new Date().toISOString(),
});
} catch (err) {
return next(err);
}
});
// POST /v1/shopping-lists
router.post('/', async (req, res, next) => {
try {
const data = validate(createShoppingListSchema, req.body);
const list = await shoppingService.createShoppingList(req.user.sub, data);
return res.status(201).json({ shopping_list: list });
} catch (err) {
return next(err);
}
});
// GET /v1/shopping-lists/:list_id
router.get('/:list_id', async (req, res, next) => {
try {
const list = await shoppingService.getShoppingListById(
req.user.sub,
req.params.list_id
);
return res.status(200).json({
shopping_list: list,
synced_at: new Date().toISOString(),
});
} catch (err) {
return next(err);
}
});
// DELETE /v1/shopping-lists/:list_id
router.delete('/:list_id', async (req, res, next) => {
try {
await shoppingService.deleteShoppingList(req.user.sub, req.params.list_id);
return res.status(204).send();
} catch (err) {
return next(err);
}
});
// POST /v1/shopping-lists/:list_id/items
router.post('/:list_id/items', async (req, res, next) => {
try {
const data = validate(addShoppingListItemSchema, req.body);
const result = await shoppingService.addItemToList(
req.user.sub,
req.params.list_id,
data
);
return res.status(201).json(result);
} catch (err) {
return next(err);
}
});
// POST /v1/shopping-lists/:list_id/add-recipes
router.post('/:list_id/add-recipes', async (req, res, next) => {
try {
const data = validate(addRecipesToListSchema, req.body);
const result = await shoppingService.addRecipesToList(
req.user.sub,
req.params.list_id,
data
);
return res.status(201).json(result);
} catch (err) {
return next(err);
}
});
// PUT /v1/shopping-lists/:list_id/items/:item_id
router.put('/:list_id/items/:item_id', async (req, res, next) => {
try {
const data = validate(updateShoppingListItemSchema, req.body);
const item = await shoppingService.updateListItem(
req.user.sub,
req.params.list_id,
req.params.item_id,
data
);
return res.status(200).json({ item });
} catch (err) {
return next(err);
}
});
// DELETE /v1/shopping-lists/:list_id/items/:item_id
router.delete('/:list_id/items/:item_id', async (req, res, next) => {
try {
await shoppingService.deleteListItem(
req.user.sub,
req.params.list_id,
req.params.item_id
);
return res.status(204).send();
} catch (err) {
return next(err);
}
});
module.exports = router;

View File

@@ -1,23 +0,0 @@
'use strict';
const express = require('express');
const router = express.Router();
const syncService = require('../services/syncService');
const { authenticate } = require('../middleware/authenticate');
const { validate } = require('../utils/validate');
const { syncQuerySchema } = require('../utils/validation');
router.use(authenticate);
// GET /v1/sync?since=<ISO8601>
router.get('/', async (req, res, next) => {
try {
const query = validate(syncQuerySchema, req.query);
const data = await syncService.getSyncData(req.user.sub, query.since || null);
return res.status(200).json(data);
} catch (err) {
return next(err);
}
});
module.exports = router;

View File

@@ -1,294 +0,0 @@
'use strict';
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { getDb } = require('../db/knex');
const { AppError } = require('../utils/errors');
const { issueToken } = require('../utils/jwt');
const config = require('../config');
const SALT_ROUNDS = 12;
/**
* Formats a user row for API responses.
* Never exposes password_hash, google_id, or internal fields.
*/
function formatUser(row) {
return {
id: row.id,
email: row.email,
name: row.name,
profile_picture_url: row.profile_picture_url || null,
email_verified: row.email_verified || false,
deleted_at: row.deleted_at || null,
created_at: row.created_at,
};
}
/**
* Register a new user with email + password.
* Rejects duplicate emails (case-insensitive).
*/
async function signup({ email, password, name }) {
const db = getDb();
const normalizedEmail = email.toLowerCase().trim();
const existing = await db('users')
.whereRaw('LOWER(email) = ?', [normalizedEmail])
.first();
if (existing) {
throw new AppError(409, 'CONFLICT', 'Email already registered.');
}
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
const [user] = await db('users')
.insert({
email: normalizedEmail,
password_hash,
name: name.trim(),
email_verified: false,
})
.returning('*');
const { token, expires_at } = issueToken(user);
return { user: formatUser(user), token, expires_at };
}
/**
* Sign in with email + password.
* Returns 401 for any credential failure — no enumeration.
* Returns 403 if account is soft-deleted within the 15-day window.
*/
async function signin({ email, password }) {
const db = getDb();
const normalizedEmail = email.toLowerCase().trim();
const user = await db('users')
.whereRaw('LOWER(email) = ?', [normalizedEmail])
.first();
// Constant-time path: always attempt bcrypt compare to prevent timing attacks
const dummyHash = '$2b$12$invalidhashfortimingprotection000000000000000000000000';
const hashToCompare = user && user.password_hash ? user.password_hash : dummyHash;
const valid = await bcrypt.compare(password, hashToCompare);
if (!user || !user.password_hash || !valid) {
throw new AppError(401, 'UNAUTHORIZED', 'Invalid email or password.');
}
if (user.deleted_at) {
// Within 15-day window — allow sign-in but signal pending deletion
if (user.deletion_scheduled_at && new Date(user.deletion_scheduled_at) > new Date()) {
throw new AppError(403, 'ACCOUNT_PENDING_DELETION', 'Account is pending deletion.', {
deletion_scheduled_at: user.deletion_scheduled_at,
can_restore: true,
});
}
// Past window — should have been hard-deleted by cron, but guard anyway
throw new AppError(401, 'UNAUTHORIZED', 'Invalid email or password.');
}
const { token, expires_at } = issueToken(user);
return { user: formatUser(user), token, expires_at };
}
/**
* Sign in or register via Google ID token.
* Decodes the JWT payload — production should verify with Google's tokeninfo endpoint.
*/
async function googleAuth({ id_token }) {
let payload;
try {
const parts = id_token.split('.');
if (parts.length !== 3) throw new Error('Malformed JWT');
// Add padding for base64url decode
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
payload = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
} catch {
throw new AppError(401, 'INVALID_TOKEN', 'Google token verification failed.');
}
if (!payload.sub || !payload.email) {
throw new AppError(401, 'INVALID_TOKEN', 'Google token verification failed.');
}
const db = getDb();
let user = await db('users').where({ google_id: payload.sub }).first();
let is_new_user = false;
if (!user) {
// Check if email already exists — link accounts
user = await db('users')
.whereRaw('LOWER(email) = ?', [payload.email.toLowerCase()])
.first();
if (user) {
const [updated] = await db('users')
.where({ id: user.id })
.update({
google_id: payload.sub,
profile_picture_url: payload.picture || user.profile_picture_url,
})
.returning('*');
user = updated;
} else {
is_new_user = true;
const [created] = await db('users')
.insert({
email: payload.email.toLowerCase(),
name: payload.name || payload.email,
profile_picture_url: payload.picture || null,
google_id: payload.sub,
email_verified: true, // Google accounts are pre-verified
})
.returning('*');
user = created;
}
}
const { token, expires_at } = issueToken(user);
return { user: formatUser(user), token, expires_at, is_new_user };
}
/**
* Request a password reset email.
* Always returns success — never reveals whether the email exists.
*/
async function requestPasswordReset({ email }) {
const db = getDb();
const normalizedEmail = email.toLowerCase().trim();
const user = await db('users')
.whereRaw('LOWER(email) = ?', [normalizedEmail])
.whereNull('deleted_at')
.first();
// Silent return — no enumeration
if (!user || !user.password_hash) return;
// Generate a cryptographically random token
const rawToken = crypto.randomBytes(32).toString('hex');
const token_hash = crypto.createHash('sha256').update(rawToken).digest('hex');
const expires_at = new Date(Date.now() + config.resetTokenExpiryHours * 60 * 60 * 1000);
await db('password_reset_tokens').insert({
user_id: user.id,
token_hash,
expires_at,
});
// Production: send email with link containing rawToken
// await emailService.sendPasswordReset(user.email, rawToken);
console.log(`[PASSWORD RESET] raw_token=${rawToken} user_id=${user.id}`);
}
/**
* Complete a password reset using the token from the email link.
* Token is SHA-256 hashed before DB lookup — raw token never stored.
*/
async function confirmPasswordReset({ token, new_password }) {
const db = getDb();
const token_hash = crypto.createHash('sha256').update(token).digest('hex');
const record = await db('password_reset_tokens').where({ token_hash }).first();
if (!record || record.used_at) {
throw new AppError(401, 'INVALID_TOKEN', 'Token is invalid or has already been used.');
}
if (new Date(record.expires_at) < new Date()) {
throw new AppError(401, 'INVALID_TOKEN', 'Token has expired.');
}
const password_hash = await bcrypt.hash(new_password, SALT_ROUNDS);
await db.transaction(async (trx) => {
await trx('users').where({ id: record.user_id }).update({ password_hash });
await trx('password_reset_tokens')
.where({ id: record.id })
.update({ used_at: new Date() });
});
}
/**
* Soft-delete a user account.
* Sets deleted_at and deletion_scheduled_at (now + 15 days).
* Hard delete is handled by the daily cron job.
*/
async function deleteAccount(userId) {
const db = getDb();
const now = new Date();
const deletion_scheduled_at = new Date(
now.getTime() + config.accountDeletionDays * 24 * 60 * 60 * 1000
);
await db('users').where({ id: userId }).update({
deleted_at: now,
deletion_scheduled_at,
});
}
/**
* Restore a soft-deleted account within the 15-day window.
* Requires valid credentials — the token from the 403 signin response is used.
*/
async function restoreAccount({ email, password }) {
const db = getDb();
const normalizedEmail = email.toLowerCase().trim();
const user = await db('users')
.whereRaw('LOWER(email) = ?', [normalizedEmail])
.first();
const dummyHash = '$2b$12$invalidhashfortimingprotection000000000000000000000000';
const hashToCompare = user && user.password_hash ? user.password_hash : dummyHash;
const valid = await bcrypt.compare(password, hashToCompare);
if (!user || !user.password_hash || !valid) {
throw new AppError(401, 'UNAUTHORIZED', 'Invalid credentials.');
}
// Past the 15-day window
if (
user.deletion_scheduled_at &&
new Date(user.deletion_scheduled_at) <= new Date()
) {
throw new AppError(410, 'GONE', 'Account has been permanently deleted.');
}
// Account was never deleted
if (!user.deleted_at) {
const { token, expires_at } = issueToken(user);
return {
user: formatUser(user),
token,
expires_at,
message: 'Account is active.',
};
}
const [restored] = await db('users')
.where({ id: user.id })
.update({ deleted_at: null, deletion_scheduled_at: null })
.returning('*');
const { token, expires_at } = issueToken(restored);
return {
user: formatUser(restored),
token,
expires_at,
message: 'Account restored successfully.',
};
}
module.exports = {
signup,
signin,
googleAuth,
requestPasswordReset,
confirmPasswordReset,
deleteAccount,
restoreAccount,
};

View File

@@ -1,112 +0,0 @@
'use strict';
const { getDb } = require('../db/knex');
const { AppError } = require('../utils/errors');
function formatItem(row) {
return {
id: row.id,
item_name: row.item_name,
quantity: row.quantity,
last_modified: row.last_modified,
created_at: row.created_at,
};
}
/**
* Returns all pantry items for the authenticated user, ordered alphabetically.
*/
async function getPantryItems(userId) {
const db = getDb();
const items = await db('pantry_items')
.where({ user_id: userId })
.orderBy('item_name_lower', 'asc');
return items.map(formatItem);
}
/**
* Adds a new pantry item.
* Rejects with 409 if an item with the same name already exists (case-insensitive).
* Auto-merge is intentionally not performed — explicit > implicit.
*/
async function addPantryItem(userId, { item_name, quantity }) {
const db = getDb();
const trimmedName = item_name.trim();
const existing = await db('pantry_items')
.where({ user_id: userId })
.whereRaw('item_name_lower = LOWER(?)', [trimmedName])
.first();
if (existing) {
throw new AppError(
409,
'DUPLICATE_ITEM',
`'${existing.item_name}' already exists in your pantry.`,
{ existing_item: formatItem(existing) }
);
}
const [item] = await db('pantry_items')
.insert({ user_id: userId, item_name: trimmedName, quantity })
.returning('*');
return formatItem(item);
}
/**
* Updates the quantity of an existing pantry item.
* Server sets last_modified = NOW() — server clock is authoritative.
* Verifies ownership: item must belong to the requesting user.
*/
async function updatePantryItem(userId, itemId, { quantity }) {
const db = getDb();
const existing = await db('pantry_items')
.where({ id: itemId, user_id: userId })
.first();
if (!existing) {
throw new AppError(404, 'NOT_FOUND', 'Pantry item not found.');
}
const now = new Date();
const [item] = await db('pantry_items')
.where({ id: itemId, user_id: userId })
.update({ quantity, last_modified: now, updated_at: now })
.returning('*');
return formatItem(item);
}
/**
* Deletes a pantry item and records a tombstone for sync.
* Verifies ownership before deletion.
*/
async function deletePantryItem(userId, itemId) {
const db = getDb();
const existing = await db('pantry_items')
.where({ id: itemId, user_id: userId })
.first();
if (!existing) {
throw new AppError(404, 'NOT_FOUND', 'Pantry item not found.');
}
await db.transaction(async (trx) => {
await trx('pantry_items').where({ id: itemId, user_id: userId }).delete();
await trx('deleted_records').insert({
user_id: userId,
record_type: 'pantry_item',
record_id: itemId,
});
});
}
module.exports = {
getPantryItems,
addPantryItem,
updatePantryItem,
deletePantryItem,
};

View File

@@ -1,152 +0,0 @@
'use strict';
const { getDb } = require('../db/knex');
const { AppError } = require('../utils/errors');
/**
* Computes availability status for a recipe given the user's pantry.
* Matching is case-insensitive via item_name_lower.
*/
function computeAvailability(ingredients, pantryNameSet) {
const total = ingredients.length;
let available = 0;
const missing = [];
for (const ing of ingredients) {
if (pantryNameSet.has(ing.item_name_lower)) {
available++;
} else {
missing.push(ing.item_name);
}
}
let status;
if (total === 0) {
status = 'can_make';
} else if (available === total) {
status = 'can_make';
} else if (available > 0) {
status = 'partial';
} else {
status = 'missing_all';
}
return { status, available_count: available, total_count: total, missing_ingredients: missing };
}
/**
* Formats a recipe row with its ingredients for the list endpoint.
* Returns a summary (no instructions).
*/
function formatRecipeSummary(recipe, ingredients, pantryNameSet) {
const availability = computeAvailability(ingredients, pantryNameSet);
return {
id: recipe.id,
name: recipe.name,
servings: recipe.servings,
ingredient_count: ingredients.length,
availability,
};
}
/**
* Formats a full recipe detail with scaled ingredients and pantry status.
*/
function formatRecipeDetail(recipe, ingredients, pantryNameSet, scale) {
const scaledIngredients = ingredients.map((ing) => ({
id: ing.id,
item_name: ing.item_name,
quantity: parseFloat((parseFloat(ing.quantity) * scale).toFixed(4)),
unit: ing.unit,
in_pantry: pantryNameSet.has(ing.item_name_lower),
}));
const availability = computeAvailability(ingredients, pantryNameSet);
return {
id: recipe.id,
name: recipe.name,
servings: recipe.servings,
scaled_servings: recipe.servings * scale,
instructions: recipe.instructions,
ingredients: scaledIngredients,
availability,
};
}
/**
* Returns all recipes with pantry-based availability.
* Supports filter: 'all' | 'available' | 'partial'
* Supports scale: 1 | 2 | 3
*/
async function getRecipes(userId, { filter = 'all', scale = 1 }) {
const db = getDb();
// Fetch user's pantry as a Set of lowercase names for O(1) lookup
const pantryRows = await db('pantry_items')
.where({ user_id: userId })
.select('item_name_lower');
const pantryNameSet = new Set(pantryRows.map((r) => r.item_name_lower));
// Fetch all recipes with their ingredients in one query
const recipes = await db('recipes').orderBy('name', 'asc');
const recipeIds = recipes.map((r) => r.id);
let ingredientRows = [];
if (recipeIds.length > 0) {
ingredientRows = await db('recipe_ingredients')
.whereIn('recipe_id', recipeIds)
.orderBy('item_name_lower', 'asc');
}
// Group ingredients by recipe_id
const ingredientsByRecipe = {};
for (const ing of ingredientRows) {
if (!ingredientsByRecipe[ing.recipe_id]) {
ingredientsByRecipe[ing.recipe_id] = [];
}
ingredientsByRecipe[ing.recipe_id].push(ing);
}
let result = recipes.map((recipe) => {
const ingredients = ingredientsByRecipe[recipe.id] || [];
return formatRecipeSummary(recipe, ingredients, pantryNameSet);
});
// Apply filter
if (filter === 'available') {
result = result.filter((r) => r.availability.status === 'can_make');
} else if (filter === 'partial') {
result = result.filter(
(r) => r.availability.status === 'can_make' || r.availability.status === 'partial'
);
}
return result;
}
/**
* Returns full recipe detail for a single recipe.
* Verifies the recipe exists — recipes are global (not user-scoped).
*/
async function getRecipeById(userId, recipeId, { scale = 1 }) {
const db = getDb();
const recipe = await db('recipes').where({ id: recipeId }).first();
if (!recipe) {
throw new AppError(404, 'NOT_FOUND', 'Recipe not found.');
}
const ingredients = await db('recipe_ingredients')
.where({ recipe_id: recipeId })
.orderBy('item_name_lower', 'asc');
const pantryRows = await db('pantry_items')
.where({ user_id: userId })
.select('item_name_lower');
const pantryNameSet = new Set(pantryRows.map((r) => r.item_name_lower));
return formatRecipeDetail(recipe, ingredients, pantryNameSet, scale);
}
module.exports = { getRecipes, getRecipeById };

View File

@@ -1,340 +0,0 @@
'use strict';
const { getDb } = require('../db/knex');
const { AppError } = require('../utils/errors');
function formatListSummary(row) {
return {
id: row.id,
list_name: row.list_name,
item_count: parseInt(row.item_count || 0, 10),
checked_count: parseInt(row.checked_count || 0, 10),
last_modified: row.last_modified,
created_at: row.created_at,
};
}
function formatItem(row) {
return {
id: row.id,
item_name: row.item_name,
quantity: parseFloat(row.quantity),
unit: row.unit,
checked_off: row.checked_off,
last_modified: row.last_modified,
};
}
/**
* Returns all shopping lists for the user with item/checked counts.
*/
async function getShoppingLists(userId) {
const db = getDb();
const lists = await db('shopping_lists as sl')
.leftJoin('shopping_list_items as sli', 'sl.id', 'sli.shopping_list_id')
.where('sl.user_id', userId)
.groupBy('sl.id')
.select(
'sl.id',
'sl.list_name',
'sl.last_modified',
'sl.created_at',
db.raw('COUNT(sli.id) AS item_count'),
db.raw('COUNT(sli.id) FILTER (WHERE sli.checked_off = true) AS checked_count')
)
.orderBy('sl.created_at', 'desc');
return lists.map(formatListSummary);
}
/**
* Creates a new empty shopping list.
*/
async function createShoppingList(userId, { list_name }) {
const db = getDb();
const [list] = await db('shopping_lists')
.insert({ user_id: userId, list_name: list_name.trim() })
.returning('*');
return {
id: list.id,
list_name: list.list_name,
item_count: 0,
checked_count: 0,
last_modified: list.last_modified,
created_at: list.created_at,
};
}
/**
* Returns a shopping list with all its items.
* Verifies ownership.
*/
async function getShoppingListById(userId, listId) {
const db = getDb();
const list = await db('shopping_lists')
.where({ id: listId, user_id: userId })
.first();
if (!list) {
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
}
const items = await db('shopping_list_items')
.where({ shopping_list_id: listId })
.orderBy('item_name_lower', 'asc');
return {
id: list.id,
list_name: list.list_name,
last_modified: list.last_modified,
created_at: list.created_at,
items: items.map(formatItem),
};
}
/**
* Deletes a shopping list and all its items.
* Records tombstones for sync.
* Verifies ownership.
*/
async function deleteShoppingList(userId, listId) {
const db = getDb();
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
if (!list) {
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
}
await db.transaction(async (trx) => {
const items = await trx('shopping_list_items')
.where({ shopping_list_id: listId })
.select('id');
for (const item of items) {
await trx('deleted_records').insert({
user_id: userId,
record_type: 'shopping_list_item',
record_id: item.id,
});
}
await trx('shopping_lists').where({ id: listId, user_id: userId }).delete();
await trx('deleted_records').insert({
user_id: userId,
record_type: 'shopping_list',
record_id: listId,
});
});
}
/**
* Adds an item to a shopping list.
* Merge logic: same name (case-insensitive) AND same unit → sum quantities.
* Different units → separate line items.
* Verifies list ownership.
*/
async function addItemToList(userId, listId, { item_name, quantity, unit }) {
const db = getDb();
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
if (!list) {
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
}
const trimmedName = item_name.trim();
const now = new Date();
const existing = await db('shopping_list_items')
.where({ shopping_list_id: listId, unit })
.whereRaw('item_name_lower = LOWER(?)', [trimmedName])
.first();
if (existing) {
const newQty = parseFloat((parseFloat(existing.quantity) + quantity).toFixed(4));
const previousQuantity = parseFloat(existing.quantity);
const [updated] = await db('shopping_list_items')
.where({ id: existing.id })
.update({ quantity: newQty, last_modified: now, updated_at: now })
.returning('*');
await db('shopping_lists')
.where({ id: listId })
.update({ last_modified: now, updated_at: now });
return { item: formatItem(updated), merged: true, previous_quantity: previousQuantity };
}
const [item] = await db('shopping_list_items')
.insert({ shopping_list_id: listId, item_name: trimmedName, quantity, unit })
.returning('*');
await db('shopping_lists')
.where({ id: listId })
.update({ last_modified: now, updated_at: now });
return { item: formatItem(item), merged: false };
}
/**
* Updates a shopping list item (quantity, unit, checked_off).
* All fields are optional — only provided fields are patched.
* Verifies list ownership and item membership.
*/
async function updateListItem(userId, listId, itemId, updates) {
const db = getDb();
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
if (!list) {
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
}
const item = await db('shopping_list_items')
.where({ id: itemId, shopping_list_id: listId })
.first();
if (!item) {
throw new AppError(404, 'NOT_FOUND', 'Shopping list item not found.');
}
const now = new Date();
const patch = { last_modified: now, updated_at: now };
if (updates.quantity !== undefined) patch.quantity = updates.quantity;
if (updates.unit !== undefined) patch.unit = updates.unit;
if (updates.checked_off !== undefined) patch.checked_off = updates.checked_off;
const [updated] = await db('shopping_list_items')
.where({ id: itemId })
.update(patch)
.returning('*');
await db('shopping_lists')
.where({ id: listId })
.update({ last_modified: now, updated_at: now });
return formatItem(updated);
}
/**
* Deletes a shopping list item and records a tombstone for sync.
* Verifies list ownership and item membership.
*/
async function deleteListItem(userId, listId, itemId) {
const db = getDb();
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
if (!list) {
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
}
const item = await db('shopping_list_items')
.where({ id: itemId, shopping_list_id: listId })
.first();
if (!item) {
throw new AppError(404, 'NOT_FOUND', 'Shopping list item not found.');
}
const now = new Date();
await db.transaction(async (trx) => {
await trx('shopping_list_items').where({ id: itemId }).delete();
await trx('deleted_records').insert({
user_id: userId,
record_type: 'shopping_list_item',
record_id: itemId,
});
await trx('shopping_lists')
.where({ id: listId })
.update({ last_modified: now, updated_at: now });
});
}
/**
* Adds ingredients from one or more recipes to a shopping list.
* Merge logic: same name (case-insensitive) + same unit → sum quantities.
* Different units → separate line items.
* scale: 1 | 2 | 3 — multiplies all ingredient quantities.
*/
async function addRecipesToList(userId, listId, { recipe_ids, scale = 1 }) {
const db = getDb();
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
if (!list) {
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
}
// Validate all recipes exist before touching the list
const recipes = await db('recipes').whereIn('id', recipe_ids);
if (recipes.length !== recipe_ids.length) {
throw new AppError(404, 'NOT_FOUND', 'One or more recipes not found.');
}
const recipeIngredients = await db('recipe_ingredients').whereIn('recipe_id', recipe_ids);
let itemsMerged = 0;
let itemsCreated = 0;
const now = new Date();
await db.transaction(async (trx) => {
for (const ing of recipeIngredients) {
const scaledQty = parseFloat((parseFloat(ing.quantity) * scale).toFixed(4));
const existing = await trx('shopping_list_items')
.where({ shopping_list_id: listId, unit: ing.unit })
.whereRaw('item_name_lower = LOWER(?)', [ing.item_name])
.first();
if (existing) {
const newQty = parseFloat((parseFloat(existing.quantity) + scaledQty).toFixed(4));
await trx('shopping_list_items')
.where({ id: existing.id })
.update({ quantity: newQty, last_modified: now, updated_at: now });
itemsMerged++;
} else {
await trx('shopping_list_items').insert({
shopping_list_id: listId,
item_name: ing.item_name,
quantity: scaledQty,
unit: ing.unit,
});
itemsCreated++;
}
}
await trx('shopping_lists')
.where({ id: listId })
.update({ last_modified: now, updated_at: now });
});
const updatedItems = await db('shopping_list_items')
.where({ shopping_list_id: listId })
.orderBy('item_name_lower', 'asc');
return {
shopping_list: {
id: list.id,
list_name: list.list_name,
last_modified: now,
items: updatedItems.map(formatItem),
},
recipes_added: recipes.length,
items_merged: itemsMerged,
items_created: itemsCreated,
};
}
module.exports = {
getShoppingLists,
createShoppingList,
getShoppingListById,
deleteShoppingList,
addItemToList,
updateListItem,
deleteListItem,
addRecipesToList,
};

View File

@@ -1,106 +0,0 @@
'use strict';
const { getDb } = require('../db/knex');
/**
* Returns all data modified since `since` timestamp.
* If `since` is null, returns everything (full sync).
* Includes tombstone records so the client can remove deleted items from cache.
*/
async function getSyncData(userId, since) {
const db = getDb();
const serverTimestamp = new Date().toISOString();
const isFullSync = !since;
// ── Pantry ──────────────────────────────────────────────────────────────────
let pantryQuery = db('pantry_items').where({ user_id: userId });
if (since) {
pantryQuery = pantryQuery.where('last_modified', '>', since);
}
const pantryItems = await pantryQuery.orderBy('item_name_lower', 'asc');
let deletedPantryIds = [];
if (since) {
const tombstones = await db('deleted_records')
.where({ user_id: userId, record_type: 'pantry_item' })
.where('deleted_at', '>', since)
.select('record_id');
deletedPantryIds = tombstones.map((r) => r.record_id);
}
// ── Shopping Lists ───────────────────────────────────────────────────────────
let listsQuery = db('shopping_lists').where({ user_id: userId });
if (since) {
listsQuery = listsQuery.where('last_modified', '>', since);
}
const lists = await listsQuery.orderBy('created_at', 'asc');
const listIds = lists.map((l) => l.id);
let listItems = [];
if (listIds.length > 0) {
listItems = await db('shopping_list_items')
.whereIn('shopping_list_id', listIds)
.orderBy('item_name_lower', 'asc');
}
// Group items by list
const itemsByList = {};
for (const item of listItems) {
if (!itemsByList[item.shopping_list_id]) {
itemsByList[item.shopping_list_id] = [];
}
itemsByList[item.shopping_list_id].push({
id: item.id,
item_name: item.item_name,
quantity: parseFloat(item.quantity),
unit: item.unit,
checked_off: item.checked_off,
last_modified: item.last_modified,
deleted: false,
});
}
let deletedListIds = [];
let deletedItemIds = [];
if (since) {
const deletedLists = await db('deleted_records')
.where({ user_id: userId, record_type: 'shopping_list' })
.where('deleted_at', '>', since)
.select('record_id');
deletedListIds = deletedLists.map((r) => r.record_id);
const deletedItems = await db('deleted_records')
.where({ user_id: userId, record_type: 'shopping_list_item' })
.where('deleted_at', '>', since)
.select('record_id');
deletedItemIds = deletedItems.map((r) => r.record_id);
}
return {
pantry: {
items: pantryItems.map((p) => ({
id: p.id,
item_name: p.item_name,
quantity: p.quantity,
last_modified: p.last_modified,
deleted: false,
})),
deleted_ids: deletedPantryIds,
},
shopping_lists: {
lists: lists.map((l) => ({
id: l.id,
list_name: l.list_name,
last_modified: l.last_modified,
deleted: false,
items: itemsByList[l.id] || [],
})),
deleted_list_ids: deletedListIds,
deleted_item_ids: deletedItemIds,
},
server_timestamp: serverTimestamp,
full_sync: isFullSync,
};
}
module.exports = { getSyncData };

View File

@@ -1,21 +0,0 @@
'use strict';
/**
* Application-level error.
* status — HTTP status code
* code — machine-readable error code
* message — human-readable message
* extra — optional additional fields merged into the response body
*/
class AppError extends Error {
constructor(status, code, message, extra = null) {
super(message);
this.name = 'AppError';
this.status = status;
this.code = code;
this.isAppError = true;
this.extra = extra;
}
}
module.exports = { AppError };

View File

@@ -1,26 +0,0 @@
'use strict';
const jwt = require('jsonwebtoken');
const config = require('../config');
/**
* Issues a signed JWT for the given user record.
* Returns { token, expires_at }.
*/
function issueToken(user) {
const payload = {
sub: user.id,
email: user.email,
};
const token = jwt.sign(payload, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
});
const decoded = jwt.decode(token);
const expires_at = new Date(decoded.exp * 1000).toISOString();
return { token, expires_at };
}
module.exports = { issueToken };

View File

@@ -1,21 +0,0 @@
'use strict';
const { AppError } = require('./errors');
/**
* Validates data against a Zod schema.
* Throws a structured AppError on failure so the central error handler
* can format it consistently.
*/
function validate(schema, data) {
const result = schema.safeParse(data);
if (!result.success) {
const err = new AppError(400, 'VALIDATION_ERROR', 'Validation failed.');
err.zodError = true;
err.issues = result.error.issues;
throw err;
}
return result.data;
}
module.exports = { validate };

View File

@@ -1,134 +0,0 @@
'use strict';
const { z } = require('zod');
// ─── Auth ────────────────────────────────────────────────────────────────────
const signupSchema = z.object({
email: z.string().email('Invalid email format.').max(255),
password: z
.string()
.min(8, 'Password must be at least 8 characters.')
.regex(/^[a-zA-Z0-9]+$/, 'Password must be alphanumeric.'),
name: z.string().min(1, 'Name is required.').max(100, 'Name must be 100 characters or fewer.'),
});
const signinSchema = z.object({
email: z.string().email('Invalid email format.'),
password: z.string().min(1, 'Password is required.'),
});
const googleAuthSchema = z.object({
id_token: z.string().min(1, 'id_token is required.'),
});
const passwordResetRequestSchema = z.object({
email: z.string().email('Invalid email format.'),
});
const passwordResetConfirmSchema = z.object({
token: z.string().min(1, 'Token is required.'),
new_password: z
.string()
.min(8, 'Password must be at least 8 characters.')
.regex(/^[a-zA-Z0-9]+$/, 'Password must be alphanumeric.'),
});
const restoreAccountSchema = z.object({
email: z.string().email('Invalid email format.'),
password: z.string().min(1, 'Password is required.'),
});
// ─── Pantry ──────────────────────────────────────────────────────────────────
const addPantryItemSchema = z.object({
item_name: z
.string()
.min(1, 'Item name is required.')
.max(200, 'Item name must be 200 characters or fewer.')
.transform((v) => v.trim())
.refine((v) => v.length > 0, 'Item name cannot be blank.'),
quantity: z
.number({ invalid_type_error: 'Quantity must be a number.' })
.int('Quantity must be a whole number.')
.positive('Quantity must be positive.'),
});
const updatePantryItemSchema = z.object({
quantity: z
.number({ invalid_type_error: 'Quantity must be a number.' })
.int('Quantity must be a whole number.')
.positive('Quantity must be positive.'),
last_modified: z.string().datetime({ offset: true }).optional(),
});
// ─── Shopping Lists ───────────────────────────────────────────────────────────
const createShoppingListSchema = z.object({
list_name: z
.string()
.min(1, 'List name is required.')
.max(200, 'List name must be 200 characters or fewer.'),
});
const addShoppingListItemSchema = z.object({
item_name: z
.string()
.min(1, 'Item name is required.')
.max(200, 'Item name must be 200 characters or fewer.'),
quantity: z
.number({ invalid_type_error: 'Quantity must be a number.' })
.positive('Quantity must be positive.'),
unit: z.string().min(1, 'Unit is required.').max(50, 'Unit must be 50 characters or fewer.'),
});
const updateShoppingListItemSchema = z
.object({
quantity: z
.number({ invalid_type_error: 'Quantity must be a number.' })
.positive('Quantity must be positive.')
.optional(),
unit: z.string().min(1).max(50).optional(),
checked_off: z.boolean().optional(),
})
.refine(
(d) => d.quantity !== undefined || d.unit !== undefined || d.checked_off !== undefined,
{ message: 'At least one field (quantity, unit, checked_off) must be provided.' }
);
const addRecipesToListSchema = z.object({
recipe_ids: z
.array(z.string().uuid('Each recipe_id must be a valid UUID.'))
.min(1, 'At least one recipe_id is required.'),
scale: z.number().int().min(1).max(3).optional().default(1),
});
// ─── Recipes ─────────────────────────────────────────────────────────────────
const recipeQuerySchema = z.object({
filter: z.enum(['all', 'available', 'partial']).optional().default('all'),
scale: z.coerce.number().int().min(1).max(3).optional().default(1),
});
// ─── Sync ─────────────────────────────────────────────────────────────────────
const syncQuerySchema = z.object({
since: z.string().datetime({ offset: true }).optional(),
});
module.exports = {
signupSchema,
signinSchema,
googleAuthSchema,
passwordResetRequestSchema,
passwordResetConfirmSchema,
restoreAccountSchema,
addPantryItemSchema,
updatePantryItemSchema,
createShoppingListSchema,
addShoppingListItemSchema,
updateShoppingListItemSchema,
addRecipesToListSchema,
recipeQuerySchema,
syncQuerySchema,
};

View File

@@ -1,240 +0,0 @@
'use strict';
const request = require('supertest');
const app = require('../../src/main/app');
const { setDb } = require('../../src/db/knex');
const { createTestDb } = require('../helpers/testDb');
describe('Auth Routes', () => {
let db;
beforeEach(() => {
db = createTestDb();
setDb(db);
});
// ── POST /v1/auth/signup ────────────────────────────────────────────────────
describe('POST /v1/auth/signup', () => {
it('creates a new user and returns token', async () => {
const res = await request(app)
.post('/v1/auth/signup')
.send({ email: 'jane@example.com', password: 'password1', name: 'Jane Doe' });
expect(res.status).toBe(201);
expect(res.body.user.email).toBe('jane@example.com');
expect(res.body.user.name).toBe('Jane Doe');
expect(res.body.token).toBeDefined();
expect(res.body.expires_at).toBeDefined();
// password_hash must never appear in response
expect(res.body.user.password_hash).toBeUndefined();
});
it('lowercases email on storage', async () => {
const res = await request(app)
.post('/v1/auth/signup')
.send({ email: 'JANE@EXAMPLE.COM', password: 'password1', name: 'Jane' });
expect(res.status).toBe(201);
expect(res.body.user.email).toBe('jane@example.com');
});
it('returns 409 when email already registered', async () => {
await request(app)
.post('/v1/auth/signup')
.send({ email: 'jane@example.com', password: 'password1', name: 'Jane' });
const res = await request(app)
.post('/v1/auth/signup')
.send({ email: 'jane@example.com', password: 'password1', name: 'Jane Again' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('CONFLICT');
});
it('returns 409 for case-insensitive duplicate email', async () => {
await request(app)
.post('/v1/auth/signup')
.send({ email: 'jane@example.com', password: 'password1', name: 'Jane' });
const res = await request(app)
.post('/v1/auth/signup')
.send({ email: 'JANE@EXAMPLE.COM', password: 'password1', name: 'Jane' });
expect(res.status).toBe(409);
});
it('returns 400 for invalid email', async () => {
const res = await request(app)
.post('/v1/auth/signup')
.send({ email: 'not-an-email', password: 'password1', name: 'Jane' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
it('returns 400 for password shorter than 8 chars', async () => {
const res = await request(app)
.post('/v1/auth/signup')
.send({ email: 'jane@example.com', password: 'short', name: 'Jane' });
expect(res.status).toBe(400);
});
it('returns 400 for non-alphanumeric password', async () => {
const res = await request(app)
.post('/v1/auth/signup')
.send({ email: 'jane@example.com', password: 'p@ssword1', name: 'Jane' });
expect(res.status).toBe(400);
});
it('returns 400 when name is missing', async () => {
const res = await request(app)
.post('/v1/auth/signup')
.send({ email: 'jane@example.com', password: 'password1' });
expect(res.status).toBe(400);
});
it('returns 400 when body is empty', async () => {
const res = await request(app).post('/v1/auth/signup').send({});
expect(res.status).toBe(400);
});
});
// ── POST /v1/auth/signin ────────────────────────────────────────────────────
describe('POST /v1/auth/signin', () => {
beforeEach(async () => {
await request(app)
.post('/v1/auth/signup')
.send({ email: 'jane@example.com', password: 'password1', name: 'Jane' });
});
it('returns token on valid credentials', async () => {
const res = await request(app)
.post('/v1/auth/signin')
.send({ email: 'jane@example.com', password: 'password1' });
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
expect(res.body.user.email).toBe('jane@example.com');
});
it('is case-insensitive for email', async () => {
const res = await request(app)
.post('/v1/auth/signin')
.send({ email: 'JANE@EXAMPLE.COM', password: 'password1' });
expect(res.status).toBe(200);
});
it('returns 401 for wrong password', async () => {
const res = await request(app)
.post('/v1/auth/signin')
.send({ email: 'jane@example.com', password: 'wrongpass' });
expect(res.status).toBe(401);
expect(res.body.code).toBe('UNAUTHORIZED');
});
it('returns 401 for unknown email', async () => {
const res = await request(app)
.post('/v1/auth/signin')
.send({ email: 'nobody@example.com', password: 'password1' });
expect(res.status).toBe(401);
});
it('returns 400 when fields are missing', async () => {
const res = await request(app)
.post('/v1/auth/signin')
.send({ email: 'jane@example.com' });
expect(res.status).toBe(400);
});
});
// ── POST /v1/auth/password-reset ────────────────────────────────────────────
describe('POST /v1/auth/password-reset', () => {
it('always returns 200 regardless of email existence', async () => {
const res = await request(app)
.post('/v1/auth/password-reset')
.send({ email: 'nobody@example.com' });
expect(res.status).toBe(200);
expect(res.body.message).toMatch(/reset link/i);
});
it('returns 400 for invalid email', async () => {
const res = await request(app)
.post('/v1/auth/password-reset')
.send({ email: 'not-an-email' });
expect(res.status).toBe(400);
});
});
// ── DELETE /v1/auth/account ─────────────────────────────────────────────────
describe('DELETE /v1/auth/account', () => {
it('soft-deletes the authenticated user account', async () => {
const signupRes = await request(app)
.post('/v1/auth/signup')
.send({ email: 'delete@example.com', password: 'password1', name: 'Delete Me' });
const token = signupRes.body.token;
const res = await request(app)
.delete('/v1/auth/account')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(204);
// Verify account is soft-deleted
const users = db.getTable('users');
const user = users.find((u) => u.email === 'delete@example.com');
expect(user.deleted_at).toBeDefined();
expect(user.deletion_scheduled_at).toBeDefined();
});
it('returns 401 without token', async () => {
const res = await request(app).delete('/v1/auth/account');
expect(res.status).toBe(401);
});
});
// ── POST /v1/auth/restore-account ──────────────────────────────────────────
describe('POST /v1/auth/restore-account', () => {
it('restores a soft-deleted account', async () => {
const signupRes = await request(app)
.post('/v1/auth/signup')
.send({ email: 'restore@example.com', password: 'password1', name: 'Restore Me' });
const token = signupRes.body.token;
await request(app)
.delete('/v1/auth/account')
.set('Authorization', `Bearer ${token}`);
const res = await request(app)
.post('/v1/auth/restore-account')
.send({ email: 'restore@example.com', password: 'password1' });
expect(res.status).toBe(200);
expect(res.body.user.deleted_at).toBeNull();
expect(res.body.message).toMatch(/restored/i);
});
it('returns 401 for wrong credentials', async () => {
const res = await request(app)
.post('/v1/auth/restore-account')
.send({ email: 'nobody@example.com', password: 'password1' });
expect(res.status).toBe(401);
});
});
});

View File

@@ -1,305 +0,0 @@
'use strict';
/**
* In-memory database stub for tests.
* Replaces the real Knex/PostgreSQL connection with a lightweight
* object that simulates table operations using plain JS Maps.
*
* Usage: const db = createTestDb(); setDb(db);
*/
function makeTable(rows = []) {
return [...rows];
}
/**
* Creates a minimal Knex-like query builder stub backed by in-memory arrays.
* Supports: insert, where, whereRaw, whereIn, whereNull, whereNotNull,
* first, select, update, delete, returning, orderBy,
* leftJoin, groupBy, raw, transaction.
*/
function createTestDb(initialData = {}) {
const tables = {};
function getTable(name) {
if (!tables[name]) tables[name] = [];
return tables[name];
}
function seedTable(name, rows) {
tables[name] = [...rows];
}
// Seed initial data
for (const [name, rows] of Object.entries(initialData)) {
seedTable(name, rows);
}
// ── Query Builder ────────────────────────────────────────────────────────────
function queryBuilder(tableName) {
let _rows = null; // lazy — resolved on terminal op
let _filters = [];
let _insertData = null;
let _updateData = null;
let _doDelete = false;
let _returning = false;
let _orderByField = null;
let _orderByDir = 'asc';
let _selectFields = null;
let _limit1 = false;
let _joins = [];
let _groupByField = null;
let _rawSelects = [];
function getRows() {
if (_rows !== null) return _rows;
return getTable(tableName);
}
function applyFilters(rows) {
return rows.filter((row) => {
for (const f of _filters) {
if (!f(row)) return false;
}
return true;
});
}
const qb = {
where(condOrField, val) {
if (typeof condOrField === 'object') {
for (const [k, v] of Object.entries(condOrField)) {
_filters.push((row) => row[k] === v);
}
} else {
_filters.push((row) => row[condOrField] === val);
}
return qb;
},
whereRaw(expr, params) {
// Support: 'LOWER(email) = ?' and 'item_name_lower = LOWER(?)'
const lowerEmailMatch = expr.match(/LOWER\((\w+)\)\s*=\s*\?/i);
const lowerFieldMatch = expr.match(/(\w+)\s*=\s*LOWER\(\?\)/i);
if (lowerEmailMatch) {
const field = lowerEmailMatch[1];
const val = params[0].toLowerCase();
_filters.push((row) => (row[field] || '').toLowerCase() === val);
} else if (lowerFieldMatch) {
const field = lowerFieldMatch[1];
const val = params[0].toLowerCase();
_filters.push((row) => (row[field] || '') === val);
}
return qb;
},
whereIn(field, vals) {
_filters.push((row) => vals.includes(row[field]));
return qb;
},
whereNull(field) {
_filters.push((row) => row[field] == null);
return qb;
},
whereNotNull(field) {
_filters.push((row) => row[field] != null);
return qb;
},
select(...fields) {
_selectFields = fields.flat();
return qb;
},
orderBy(field, dir = 'asc') {
_orderByField = field;
_orderByDir = dir;
return qb;
},
groupBy() {
_groupByField = true;
return qb;
},
leftJoin(otherTable, leftKey, rightKey) {
_joins.push({ otherTable, leftKey, rightKey });
return qb;
},
raw(sql) {
_rawSelects.push(sql);
return sql; // knex.raw returns the string in our stub
},
insert(data) {
_insertData = Array.isArray(data) ? data : [data];
return qb;
},
update(data) {
_updateData = data;
return qb;
},
delete() {
_doDelete = true;
return qb;
},
returning() {
_returning = true;
return qb;
},
first() {
_limit1 = true;
return qb;
},
// Terminal: await qb
then(resolve, reject) {
try {
resolve(execute());
} catch (e) {
reject(e);
}
},
};
function execute() {
const table = getTable(tableName);
if (_insertData) {
const inserted = _insertData.map((d) => {
const row = {
id: d.id || require('uuid').v4(),
...d,
created_at: d.created_at || new Date().toISOString(),
updated_at: d.updated_at || new Date().toISOString(),
last_modified: d.last_modified || new Date().toISOString(),
};
// Compute generated columns
if (row.item_name !== undefined) {
row.item_name_lower = row.item_name.toLowerCase();
}
table.push(row);
return row;
});
return _returning ? inserted : inserted.length;
}
if (_doDelete) {
const before = table.length;
const toDelete = applyFilters(table);
for (const row of toDelete) {
const idx = table.indexOf(row);
if (idx !== -1) table.splice(idx, 1);
}
return _returning ? toDelete : before - table.length;
}
if (_updateData) {
const matched = applyFilters(table);
const updated = matched.map((row) => {
Object.assign(row, _updateData, {
updated_at: new Date().toISOString(),
});
if (row.item_name !== undefined) {
row.item_name_lower = row.item_name.toLowerCase();
}
return row;
});
return _returning ? updated : updated.length;
}
// SELECT
let rows = applyFilters(table);
// Apply joins (simplified — just attach joined fields)
for (const join of _joins) {
const otherRows = getTable(join.otherTable);
const [leftTable, leftField] = join.leftKey.split('.');
const [, rightField] = join.rightKey.split('.');
rows = rows.map((row) => {
const matches = otherRows.filter((o) => o[rightField] === row[leftField || 'id']);
if (matches.length === 0) return { ...row, _joined: [] };
return matches.map((m) => ({ ...row, ...m, _joined: true }));
}).flat();
}
if (_orderByField) {
rows = [...rows].sort((a, b) => {
const av = a[_orderByField] || '';
const bv = b[_orderByField] || '';
const cmp = String(av).localeCompare(String(bv));
return _orderByDir === 'desc' ? -cmp : cmp;
});
}
// Aggregate for groupBy (count)
if (_groupByField) {
const grouped = {};
for (const row of rows) {
const key = row.id;
if (!grouped[key]) {
grouped[key] = { ...row, item_count: 0, checked_count: 0 };
}
if (row._joined) {
grouped[key].item_count++;
if (row.checked_off) grouped[key].checked_count++;
}
}
rows = Object.values(grouped);
}
if (_limit1) return rows[0] || null;
return rows;
}
return qb;
}
// ── Transaction stub ─────────────────────────────────────────────────────────
async function transaction(fn) {
// Pass the same db proxy as the transaction context
return fn(proxy);
}
// ── Raw stub ─────────────────────────────────────────────────────────────────
function raw(sql) {
return sql;
}
const proxy = new Proxy(
{ transaction, raw, _tables: tables, seedTable, getTable },
{
get(target, prop) {
if (prop in target) return target[prop];
return (tableName) => queryBuilder(tableName || prop);
},
apply(target, thisArg, args) {
return queryBuilder(args[0]);
},
}
);
// Make proxy callable as a function: db('tableName')
return new Proxy(function () {}, {
apply(target, thisArg, args) {
return queryBuilder(args[0]);
},
get(target, prop) {
if (prop === 'transaction') return transaction;
if (prop === 'raw') return raw;
if (prop === '_tables') return tables;
if (prop === 'seedTable') return seedTable;
if (prop === 'getTable') return getTable;
return (tableName) => queryBuilder(tableName || prop);
},
});
}
module.exports = { createTestDb };

View File

@@ -1,308 +0,0 @@
'use strict';
const request = require('supertest');
const app = require('../../src/main/app');
const { setDb } = require('../../src/db/knex');
const { createTestDb } = require('../helpers/testDb');
const { issueToken } = require('../../src/utils/jwt');
function makeUser(overrides = {}) {
return {
id: require('uuid').v4(),
email: 'jane@example.com',
name: 'Jane Doe',
profile_picture_url: null,
email_verified: true,
deleted_at: null,
deletion_scheduled_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
describe('Pantry Routes', () => {
let db;
let user;
let token;
beforeEach(() => {
user = makeUser();
db = createTestDb({ users: [user] });
setDb(db);
token = issueToken(user).token;
});
// ── GET /v1/pantry ──────────────────────────────────────────────────────────
describe('GET /v1/pantry', () => {
it('returns empty items array for new user', async () => {
const res = await request(app)
.get('/v1/pantry')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.items).toEqual([]);
expect(res.body.synced_at).toBeDefined();
});
it('returns pantry items for authenticated user', async () => {
db.seedTable('pantry_items', [
{
id: 'item-1',
user_id: user.id,
item_name: 'Flour',
item_name_lower: 'flour',
quantity: 5,
last_modified: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
]);
const res = await request(app)
.get('/v1/pantry')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(1);
expect(res.body.items[0].item_name).toBe('Flour');
});
it('returns 401 without token', async () => {
const res = await request(app).get('/v1/pantry');
expect(res.status).toBe(401);
});
it('does not return items belonging to other users', async () => {
const otherUser = makeUser({ id: 'other-user-id', email: 'other@example.com' });
db.seedTable('pantry_items', [
{
id: 'item-other',
user_id: otherUser.id,
item_name: 'Sugar',
item_name_lower: 'sugar',
quantity: 2,
last_modified: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
]);
const res = await request(app)
.get('/v1/pantry')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(0);
});
});
// ── POST /v1/pantry ─────────────────────────────────────────────────────────
describe('POST /v1/pantry', () => {
it('adds a new pantry item', async () => {
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Flour', quantity: 5 });
expect(res.status).toBe(201);
expect(res.body.item.item_name).toBe('Flour');
expect(res.body.item.quantity).toBe(5);
expect(res.body.item.id).toBeDefined();
});
it('returns 409 for duplicate item (case-insensitive)', async () => {
await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Flour', quantity: 5 });
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 3 });
expect(res.status).toBe(409);
expect(res.body.code).toBe('DUPLICATE_ITEM');
});
it('returns 409 for duplicate with different capitalisation', async () => {
await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Protein Powder', quantity: 1 });
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'PROTEIN POWDER', quantity: 1 });
expect(res.status).toBe(409);
});
it('returns 400 for zero quantity', async () => {
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Flour', quantity: 0 });
expect(res.status).toBe(400);
});
it('returns 400 for negative quantity', async () => {
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Flour', quantity: -1 });
expect(res.status).toBe(400);
});
it('returns 400 for fractional quantity', async () => {
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Flour', quantity: 1.5 });
expect(res.status).toBe(400);
});
it('returns 400 for missing item_name', async () => {
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ quantity: 5 });
expect(res.status).toBe(400);
});
it('returns 400 for blank item_name', async () => {
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: ' ', quantity: 5 });
expect(res.status).toBe(400);
});
it('returns 401 without token', async () => {
const res = await request(app)
.post('/v1/pantry')
.send({ item_name: 'Flour', quantity: 5 });
expect(res.status).toBe(401);
});
});
// ── PUT /v1/pantry/:item_id ─────────────────────────────────────────────────
describe('PUT /v1/pantry/:item_id', () => {
let itemId;
beforeEach(async () => {
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Flour', quantity: 5 });
itemId = res.body.item.id;
});
it('updates quantity of existing item', async () => {
const res = await request(app)
.put(`/v1/pantry/${itemId}`)
.set('Authorization', `Bearer ${token}`)
.send({ quantity: 10 });
expect(res.status).toBe(200);
expect(res.body.item.quantity).toBe(10);
});
it('returns 404 for non-existent item', async () => {
const res = await request(app)
.put('/v1/pantry/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${token}`)
.send({ quantity: 10 });
expect(res.status).toBe(404);
});
it('returns 404 for item belonging to another user', async () => {
const otherUser = makeUser({ id: 'other-id', email: 'other@example.com' });
const otherToken = issueToken(otherUser).token;
const res = await request(app)
.put(`/v1/pantry/${itemId}`)
.set('Authorization', `Bearer ${otherToken}`)
.send({ quantity: 10 });
expect(res.status).toBe(404);
});
it('returns 400 for invalid quantity', async () => {
const res = await request(app)
.put(`/v1/pantry/${itemId}`)
.set('Authorization', `Bearer ${token}`)
.send({ quantity: -5 });
expect(res.status).toBe(400);
});
});
// ── DELETE /v1/pantry/:item_id ──────────────────────────────────────────────
describe('DELETE /v1/pantry/:item_id', () => {
let itemId;
beforeEach(async () => {
const res = await request(app)
.post('/v1/pantry')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Butter', quantity: 2 });
itemId = res.body.item.id;
});
it('deletes an existing pantry item', async () => {
const res = await request(app)
.delete(`/v1/pantry/${itemId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(204);
// Verify item is gone
const getRes = await request(app)
.get('/v1/pantry')
.set('Authorization', `Bearer ${token}`);
expect(getRes.body.items).toHaveLength(0);
});
it('records a tombstone for sync', async () => {
await request(app)
.delete(`/v1/pantry/${itemId}`)
.set('Authorization', `Bearer ${token}`);
const tombstones = db.getTable('deleted_records');
expect(tombstones.some((t) => t.record_id === itemId)).toBe(true);
});
it('returns 404 for non-existent item', async () => {
const res = await request(app)
.delete('/v1/pantry/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
it('returns 404 for item belonging to another user', async () => {
const otherUser = makeUser({ id: 'other-id', email: 'other@example.com' });
const otherToken = issueToken(otherUser).token;
const res = await request(app)
.delete(`/v1/pantry/${itemId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(res.status).toBe(404);
});
});
});

View File

@@ -1,220 +0,0 @@
'use strict';
const request = require('supertest');
const app = require('../../src/main/app');
const { setDb } = require('../../src/db/knex');
const { createTestDb } = require('../helpers/testDb');
const { issueToken } = require('../../src/utils/jwt');
const { v4: uuidv4 } = require('uuid');
function makeUser(overrides = {}) {
return {
id: uuidv4(),
email: 'jane@example.com',
name: 'Jane Doe',
profile_picture_url: null,
email_verified: true,
deleted_at: null,
deletion_scheduled_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
const RECIPE_ID = '00000000-0000-0000-0000-000000000001';
function makeRecipe(overrides = {}) {
return {
id: RECIPE_ID,
name: 'Classic Pancakes',
instructions: '1. Mix. 2. Cook.',
servings: 4,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
function makeIngredients(recipeId = RECIPE_ID) {
return [
{
id: uuidv4(),
recipe_id: recipeId,
item_name: 'flour',
item_name_lower: 'flour',
quantity: '2.0000',
unit: 'cups',
},
{
id: uuidv4(),
recipe_id: recipeId,
item_name: 'milk',
item_name_lower: 'milk',
quantity: '1.5000',
unit: 'cups',
},
{
id: uuidv4(),
recipe_id: recipeId,
item_name: 'eggs',
item_name_lower: 'eggs',
quantity: '2.0000',
unit: 'whole',
},
];
}
describe('Recipe Routes', () => {
let db;
let user;
let token;
beforeEach(() => {
user = makeUser();
db = createTestDb({
users: [user],
recipes: [makeRecipe()],
recipe_ingredients: makeIngredients(),
pantry_items: [],
});
setDb(db);
token = issueToken(user).token;
});
// ── GET /v1/recipes ─────────────────────────────────────────────────────────
describe('GET /v1/recipes', () => {
it('returns all recipes with availability', async () => {
const res = await request(app)
.get('/v1/recipes')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.recipes).toHaveLength(1);
expect(res.body.recipes[0].name).toBe('Classic Pancakes');
expect(res.body.recipes[0].availability).toBeDefined();
expect(res.body.synced_at).toBeDefined();
});
it('shows missing_all when pantry is empty', async () => {
const res = await request(app)
.get('/v1/recipes')
.set('Authorization', `Bearer ${token}`);
expect(res.body.recipes[0].availability.status).toBe('missing_all');
expect(res.body.recipes[0].availability.available_count).toBe(0);
expect(res.body.recipes[0].availability.total_count).toBe(3);
});
it('shows can_make when all ingredients are in pantry', async () => {
db.seedTable('pantry_items', [
{ id: uuidv4(), user_id: user.id, item_name: 'flour', item_name_lower: 'flour', quantity: 5, last_modified: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() },
{ id: uuidv4(), user_id: user.id, item_name: 'milk', item_name_lower: 'milk', quantity: 2, last_modified: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() },
{ id: uuidv4(), user_id: user.id, item_name: 'eggs', item_name_lower: 'eggs', quantity: 6, last_modified: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() },
]);
const res = await request(app)
.get('/v1/recipes')
.set('Authorization', `Bearer ${token}`);
expect(res.body.recipes[0].availability.status).toBe('can_make');
expect(res.body.recipes[0].availability.available_count).toBe(3);
});
it('shows partial when some ingredients are in pantry', async () => {
db.seedTable('pantry_items', [
{ id: uuidv4(), user_id: user.id, item_name: 'flour', item_name_lower: 'flour', quantity: 5, last_modified: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() },
]);
const res = await request(app)
.get('/v1/recipes')
.set('Authorization', `Bearer ${token}`);
expect(res.body.recipes[0].availability.status).toBe('partial');
expect(res.body.recipes[0].availability.available_count).toBe(1);
expect(res.body.recipes[0].availability.missing_ingredients).toContain('milk');
});
it('filters to available only with ?filter=available', async () => {
const res = await request(app)
.get('/v1/recipes?filter=available')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.recipes).toHaveLength(0); // pantry is empty
});
it('returns 401 without token', async () => {
const res = await request(app).get('/v1/recipes');
expect(res.status).toBe(401);
});
it('returns 400 for invalid scale', async () => {
const res = await request(app)
.get('/v1/recipes?scale=5')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(400);
});
});
// ── GET /v1/recipes/:recipe_id ──────────────────────────────────────────────
describe('GET /v1/recipes/:recipe_id', () => {
it('returns full recipe detail', async () => {
const res = await request(app)
.get(`/v1/recipes/${RECIPE_ID}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.recipe.name).toBe('Classic Pancakes');
expect(res.body.recipe.instructions).toBeDefined();
expect(res.body.recipe.ingredients).toHaveLength(3);
expect(res.body.recipe.scaled_servings).toBe(4);
});
it('scales ingredient quantities with ?scale=2', async () => {
const res = await request(app)
.get(`/v1/recipes/${RECIPE_ID}?scale=2`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.recipe.scaled_servings).toBe(8);
const flour = res.body.recipe.ingredients.find((i) => i.item_name === 'flour');
expect(flour.quantity).toBe(4.0);
});
it('marks in_pantry correctly', async () => {
db.seedTable('pantry_items', [
{ id: uuidv4(), user_id: user.id, item_name: 'flour', item_name_lower: 'flour', quantity: 5, last_modified: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() },
]);
const res = await request(app)
.get(`/v1/recipes/${RECIPE_ID}`)
.set('Authorization', `Bearer ${token}`);
const flour = res.body.recipe.ingredients.find((i) => i.item_name === 'flour');
const milk = res.body.recipe.ingredients.find((i) => i.item_name === 'milk');
expect(flour.in_pantry).toBe(true);
expect(milk.in_pantry).toBe(false);
});
it('returns 404 for non-existent recipe', async () => {
const res = await request(app)
.get('/v1/recipes/00000000-0000-0000-0000-999999999999')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
expect(res.body.code).toBe('NOT_FOUND');
});
it('returns 400 for invalid scale value', async () => {
const res = await request(app)
.get(`/v1/recipes/${RECIPE_ID}?scale=10`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(400);
});
});
});

View File

@@ -1,525 +0,0 @@
'use strict';
const request = require('supertest');
const app = require('../../src/main/app');
const { setDb } = require('../../src/db/knex');
const { createTestDb } = require('../helpers/testDb');
const { issueToken } = require('../../src/utils/jwt');
const { v4: uuidv4 } = require('uuid');
function makeUser(overrides = {}) {
return {
id: uuidv4(),
email: 'jane@example.com',
name: 'Jane Doe',
profile_picture_url: null,
email_verified: true,
deleted_at: null,
deletion_scheduled_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
const RECIPE_ID = '00000000-0000-0000-0000-000000000001';
describe('Shopping List Routes', () => {
let db;
let user;
let token;
beforeEach(() => {
user = makeUser();
db = createTestDb({
users: [user],
shopping_lists: [],
shopping_list_items: [],
deleted_records: [],
recipes: [
{
id: RECIPE_ID,
name: 'Pancakes',
instructions: '1. Mix. 2. Cook.',
servings: 4,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
recipe_ingredients: [
{
id: uuidv4(),
recipe_id: RECIPE_ID,
item_name: 'flour',
item_name_lower: 'flour',
quantity: '2.0000',
unit: 'cups',
},
{
id: uuidv4(),
recipe_id: RECIPE_ID,
item_name: 'milk',
item_name_lower: 'milk',
quantity: '1.5000',
unit: 'cups',
},
],
});
setDb(db);
token = issueToken(user).token;
});
// ── GET /v1/shopping-lists ──────────────────────────────────────────────────
describe('GET /v1/shopping-lists', () => {
it('returns empty array for new user', async () => {
const res = await request(app)
.get('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.shopping_lists).toEqual([]);
expect(res.body.synced_at).toBeDefined();
});
it('returns 401 without token', async () => {
const res = await request(app).get('/v1/shopping-lists');
expect(res.status).toBe(401);
});
});
// ── POST /v1/shopping-lists ─────────────────────────────────────────────────
describe('POST /v1/shopping-lists', () => {
it('creates a new shopping list', async () => {
const res = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ list_name: 'Weekly Groceries' });
expect(res.status).toBe(201);
expect(res.body.shopping_list.list_name).toBe('Weekly Groceries');
expect(res.body.shopping_list.item_count).toBe(0);
expect(res.body.shopping_list.id).toBeDefined();
});
it('returns 400 for missing list_name', async () => {
const res = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(400);
});
it('returns 400 for empty list_name', async () => {
const res = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ list_name: '' });
expect(res.status).toBe(400);
});
it('returns 401 without token', async () => {
const res = await request(app)
.post('/v1/shopping-lists')
.send({ list_name: 'Test' });
expect(res.status).toBe(401);
});
});
// ── GET /v1/shopping-lists/:list_id ────────────────────────────────────────
describe('GET /v1/shopping-lists/:list_id', () => {
let listId;
beforeEach(async () => {
const res = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ list_name: 'My List' });
listId = res.body.shopping_list.id;
});
it('returns list with items', async () => {
const res = await request(app)
.get(`/v1/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.shopping_list.id).toBe(listId);
expect(res.body.shopping_list.items).toEqual([]);
});
it('returns 404 for non-existent list', async () => {
const res = await request(app)
.get('/v1/shopping-lists/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
it('returns 404 for list belonging to another user', async () => {
const otherUser = makeUser({ id: uuidv4(), email: 'other@example.com' });
const otherToken = issueToken(otherUser).token;
const res = await request(app)
.get(`/v1/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(res.status).toBe(404);
});
});
// ── DELETE /v1/shopping-lists/:list_id ─────────────────────────────────────
describe('DELETE /v1/shopping-lists/:list_id', () => {
let listId;
beforeEach(async () => {
const res = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ list_name: 'Delete Me' });
listId = res.body.shopping_list.id;
});
it('deletes the shopping list', async () => {
const res = await request(app)
.delete(`/v1/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(204);
const getRes = await request(app)
.get(`/v1/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${token}`);
expect(getRes.status).toBe(404);
});
it('records tombstone for sync', async () => {
await request(app)
.delete(`/v1/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${token}`);
const tombstones = db.getTable('deleted_records');
expect(tombstones.some((t) => t.record_id === listId)).toBe(true);
});
it('returns 404 for non-existent list', async () => {
const res = await request(app)
.delete('/v1/shopping-lists/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
});
// ── POST /v1/shopping-lists/:list_id/items ──────────────────────────────────
describe('POST /v1/shopping-lists/:list_id/items', () => {
let listId;
beforeEach(async () => {
const res = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ list_name: 'Groceries' });
listId = res.body.shopping_list.id;
});
it('adds an item to the list', async () => {
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 2.0, unit: 'cups' });
expect(res.status).toBe(201);
expect(res.body.item.item_name).toBe('flour');
expect(res.body.item.quantity).toBe(2.0);
expect(res.body.merged).toBe(false);
});
it('merges quantities for same name and unit', async () => {
await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 2.0, unit: 'cups' });
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 1.5, unit: 'cups' });
expect(res.status).toBe(201);
expect(res.body.merged).toBe(true);
expect(res.body.item.quantity).toBe(3.5);
expect(res.body.previous_quantity).toBe(2.0);
});
it('merges case-insensitively', async () => {
await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'Flour', quantity: 2.0, unit: 'cups' });
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'FLOUR', quantity: 1.0, unit: 'cups' });
expect(res.body.merged).toBe(true);
expect(res.body.item.quantity).toBe(3.0);
});
it('creates separate items for same name but different unit', async () => {
await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 2.0, unit: 'cups' });
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 100.0, unit: 'g' });
expect(res.body.merged).toBe(false);
const listRes = await request(app)
.get(`/v1/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${token}`);
expect(listRes.body.shopping_list.items).toHaveLength(2);
});
it('returns 400 for missing unit', async () => {
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 2.0 });
expect(res.status).toBe(400);
});
it('returns 400 for zero quantity', async () => {
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 0, unit: 'cups' });
expect(res.status).toBe(400);
});
it('returns 404 for non-existent list', async () => {
const res = await request(app)
.post('/v1/shopping-lists/00000000-0000-0000-0000-000000000000/items')
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'flour', quantity: 2.0, unit: 'cups' });
expect(res.status).toBe(404);
});
});
// ── PUT /v1/shopping-lists/:list_id/items/:item_id ──────────────────────────
describe('PUT /v1/shopping-lists/:list_id/items/:item_id', () => {
let listId;
let itemId;
beforeEach(async () => {
const listRes = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ list_name: 'Update Test' });
listId = listRes.body.shopping_list.id;
const itemRes = await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'butter', quantity: 1.0, unit: 'cups' });
itemId = itemRes.body.item.id;
});
it('updates checked_off status', async () => {
const res = await request(app)
.put(`/v1/shopping-lists/${listId}/items/${itemId}`)
.set('Authorization', `Bearer ${token}`)
.send({ checked_off: true });
expect(res.status).toBe(200);
expect(res.body.item.checked_off).toBe(true);
});
it('updates quantity', async () => {
const res = await request(app)
.put(`/v1/shopping-lists/${listId}/items/${itemId}`)
.set('Authorization', `Bearer ${token}`)
.send({ quantity: 3.5 });
expect(res.status).toBe(200);
expect(res.body.item.quantity).toBe(3.5);
});
it('returns 400 when no fields provided', async () => {
const res = await request(app)
.put(`/v1/shopping-lists/${listId}/items/${itemId}`)
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(400);
});
it('returns 404 for non-existent item', async () => {
const res = await request(app)
.put(`/v1/shopping-lists/${listId}/items/00000000-0000-0000-0000-000000000000`)
.set('Authorization', `Bearer ${token}`)
.send({ checked_off: true });
expect(res.status).toBe(404);
});
});
// ── DELETE /v1/shopping-lists/:list_id/items/:item_id ───────────────────────
describe('DELETE /v1/shopping-lists/:list_id/items/:item_id', () => {
let listId;
let itemId;
beforeEach(async () => {
const listRes = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ list_name: 'Delete Item Test' });
listId = listRes.body.shopping_list.id;
const itemRes = await request(app)
.post(`/v1/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ item_name: 'sugar', quantity: 1.0, unit: 'cups' });
itemId = itemRes.body.item.id;
});
it('deletes the item', async () => {
const res = await request(app)
.delete(`/v1/shopping-lists/${listId}/items/${itemId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(204);
const listRes = await request(app)
.get(`/v1/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${token}`);
expect(listRes.body.shopping_list.items).toHaveLength(0);
});
it('records tombstone for sync', async () => {
await request(app)
.delete(`/v1/shopping-lists/${listId}/items/${itemId}`)
.set('Authorization', `Bearer ${token}`);
const tombstones = db.getTable('deleted_records');
expect(tombstones.some((t) => t.record_id === itemId)).toBe(true);
});
it('returns 404 for non-existent item', async () => {
const res = await request(app)
.delete(`/v1/shopping-lists/${listId}/items/00000000-0000-0000-0000-000000000000`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
});
// ── POST /v1/shopping-lists/:list_id/add-recipes ────────────────────────────
describe('POST /v1/shopping-lists/:list_id/add-recipes', () => {
let listId;
beforeEach(async () => {
const res = await request(app)
.post('/v1/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ list_name: 'Recipe List' });
listId = res.body.shopping_list.id;
});
it('adds recipe ingredients to the list', async () => {
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/add-recipes`)
.set('Authorization', `Bearer ${token}`)
.send({ recipe_ids: [RECIPE_ID], scale: 1 });
expect(res.status).toBe(201);
expect(res.body.recipes_added).toBe(1);
expect(res.body.items_created).toBe(2); // flour + milk
expect(res.body.shopping_list.items).toHaveLength(2);
});
it('merges quantities when adding same recipe twice', async () => {
await request(app)
.post(`/v1/shopping-lists/${listId}/add-recipes`)
.set('Authorization', `Bearer ${token}`)
.send({ recipe_ids: [RECIPE_ID], scale: 1 });
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/add-recipes`)
.set('Authorization', `Bearer ${token}`)
.send({ recipe_ids: [RECIPE_ID], scale: 1 });
expect(res.status).toBe(201);
expect(res.body.items_merged).toBe(2);
expect(res.body.items_created).toBe(0);
const flour = res.body.shopping_list.items.find((i) => i.item_name === 'flour');
expect(flour.quantity).toBe(4.0); // 2 + 2
});
it('scales ingredient quantities', async () => {
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/add-recipes`)
.set('Authorization', `Bearer ${token}`)
.send({ recipe_ids: [RECIPE_ID], scale: 2 });
expect(res.status).toBe(201);
const flour = res.body.shopping_list.items.find((i) => i.item_name === 'flour');
expect(flour.quantity).toBe(4.0); // 2 * 2
});
it('returns 400 for empty recipe_ids', async () => {
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/add-recipes`)
.set('Authorization', `Bearer ${token}`)
.send({ recipe_ids: [] });
expect(res.status).toBe(400);
});
it('returns 400 for invalid scale', async () => {
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/add-recipes`)
.set('Authorization', `Bearer ${token}`)
.send({ recipe_ids: [RECIPE_ID], scale: 5 });
expect(res.status).toBe(400);
});
it('returns 404 for non-existent recipe', async () => {
const res = await request(app)
.post(`/v1/shopping-lists/${listId}/add-recipes`)
.set('Authorization', `Bearer ${token}`)
.send({ recipe_ids: ['00000000-0000-0000-0000-999999999999'] });
expect(res.status).toBe(404);
});
it('returns 404 for non-existent list', async () => {
const res = await request(app)
.post('/v1/shopping-lists/00000000-0000-0000-0000-000000000000/add-recipes')
.set('Authorization', `Bearer ${token}`)
.send({ recipe_ids: [RECIPE_ID] });
expect(res.status).toBe(404);
});
});
});

View File

@@ -1,166 +0,0 @@
'use strict';
const request = require('supertest');
const app = require('../../src/main/app');
const { setDb } = require('../../src/db/knex');
const { createTestDb } = require('../helpers/testDb');
const { issueToken } = require('../../src/utils/jwt');
const { v4: uuidv4 } = require('uuid');
function makeUser(overrides = {}) {
return {
id: uuidv4(),
email: 'jane@example.com',
name: 'Jane Doe',
profile_picture_url: null,
email_verified: true,
deleted_at: null,
deletion_scheduled_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
describe('Sync Route', () => {
let db;
let user;
let token;
beforeEach(() => {
user = makeUser();
db = createTestDb({
users: [user],
pantry_items: [],
shopping_lists: [],
shopping_list_items: [],
deleted_records: [],
});
setDb(db);
token = issueToken(user).token;
});
describe('GET /v1/sync', () => {
it('returns full sync when no since param', async () => {
const res = await request(app)
.get('/v1/sync')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.full_sync).toBe(true);
expect(res.body.server_timestamp).toBeDefined();
expect(res.body.pantry).toBeDefined();
expect(res.body.shopping_lists).toBeDefined();
});
it('returns delta sync when since param provided', async () => {
const since = new Date(Date.now() - 60000).toISOString(); // 1 minute ago
const res = await request(app)
.get(`/v1/sync?since=${encodeURIComponent(since)}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.full_sync).toBe(false);
});
it('includes pantry items in full sync', async () => {
db.seedTable('pantry_items', [
{
id: uuidv4(),
user_id: user.id,
item_name: 'Flour',
item_name_lower: 'flour',
quantity: 5,
last_modified: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
]);
const res = await request(app)
.get('/v1/sync')
.set('Authorization', `Bearer ${token}`);
expect(res.body.pantry.items).toHaveLength(1);
expect(res.body.pantry.items[0].item_name).toBe('Flour');
expect(res.body.pantry.items[0].deleted).toBe(false);
});
it('includes shopping lists in full sync', async () => {
const listId = uuidv4();
db.seedTable('shopping_lists', [
{
id: listId,
user_id: user.id,
list_name: 'Weekly',
last_modified: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
]);
const res = await request(app)
.get('/v1/sync')
.set('Authorization', `Bearer ${token}`);
expect(res.body.shopping_lists.lists).toHaveLength(1);
expect(res.body.shopping_lists.lists[0].list_name).toBe('Weekly');
});
it('includes deleted_ids for tombstoned pantry items', async () => {
const deletedId = uuidv4();
const since = new Date(Date.now() - 60000).toISOString();
db.seedTable('deleted_records', [
{
id: uuidv4(),
user_id: user.id,
record_type: 'pantry_item',
record_id: deletedId,
deleted_at: new Date().toISOString(),
},
]);
const res = await request(app)
.get(`/v1/sync?since=${encodeURIComponent(since)}`)
.set('Authorization', `Bearer ${token}`);
expect(res.body.pantry.deleted_ids).toContain(deletedId);
});
it('returns 401 without token', async () => {
const res = await request(app).get('/v1/sync');
expect(res.status).toBe(401);
});
it('returns 400 for invalid since timestamp', async () => {
const res = await request(app)
.get('/v1/sync?since=not-a-date')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(400);
});
it('does not return data belonging to other users', async () => {
const otherUser = makeUser({ id: uuidv4(), email: 'other@example.com' });
db.seedTable('pantry_items', [
{
id: uuidv4(),
user_id: otherUser.id,
item_name: 'Sugar',
item_name_lower: 'sugar',
quantity: 3,
last_modified: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
]);
const res = await request(app)
.get('/v1/sync')
.set('Authorization', `Bearer ${token}`);
expect(res.body.pantry.items).toHaveLength(0);
});
});
});