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
114 changed files with 6395 additions and 6523 deletions

View File

@@ -1,21 +0,0 @@
# Server
NODE_ENV=development
PORT=3000
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/pantree
# JWT
JWT_SECRET=change_this_to_a_long_random_secret_at_least_64_chars
JWT_EXPIRES_IN=24h
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id_here
# SendGrid
SENDGRID_API_KEY=your_sendgrid_api_key_here
SENDGRID_FROM_EMAIL=noreply@pantree.app
# App
FRONTEND_URL=https://pantree.app
PASSWORD_RESET_URL=https://pantree.app/reset-password

6
.gitignore vendored
View File

@@ -1,6 +0,0 @@
node_modules/
dist/
.env
*.log
coverage/
.DS_Store

View File

@@ -1,76 +0,0 @@
# Pantree Android
Android client for Pantree — the app that tells you what you can cook with what you already have.
## Architecture
- **MVVM** — ViewModel + StateFlow, no LiveData
- **Hilt** — dependency injection
- **Room** — local cache (pantry items, recipes, shopping lists)
- **Retrofit + OkHttp** — network layer with JWT interceptor
- **Jetpack Compose** — declarative UI, Material 3
- **EncryptedSharedPreferences** — JWT stored at rest
- **DataStore** — last-sync timestamp
## Module Structure
```
app/src/main/java/com/pantree/app/
├── data/
│ ├── local/ # Room DB, DAOs, entities, TokenStore, SyncPreferences
│ ├── model/ # API DTOs (request/response)
│ ├── remote/ # ApiService (Retrofit), AuthInterceptor
│ └── repository/ # AuthRepository, PantryRepository, RecipeRepository,
│ # ShoppingListRepository, SyncRepository
├── di/ # Hilt AppModule
├── sync/ # SyncManager (lifecycle observer)
├── ui/
│ ├── components/ # LoadingState, ErrorState, EmptyState, OfflineBanner, PantreeTopBar
│ ├── navigation/ # Screen sealed class, PantreeNavGraph
│ ├── screens/
│ │ ├── auth/ # SplashScreen, SignInScreen, SignUpScreen,
│ │ │ # ForgotPasswordScreen, ResetPasswordScreen + AuthViewModel
│ │ ├── pantry/ # PantryScreen + PantryViewModel
│ │ ├── recipe/ # RecipesScreen, RecipeDetailScreen + RecipeViewModel
│ │ ├── shopping/ # ShoppingListsScreen, ShoppingListDetailScreen + ShoppingListViewModel
│ │ └── settings/ # SettingsScreen + SettingsViewModel
│ └── theme/ # Color, Type, Theme
└── util/ # Result<T>, safeApiCall, toUserMessage
```
## UI States
Every screen handles all four states:
| State | Implementation |
|-------|---------------|
| **Loading** | `LoadingState` composable — spinner + contextual message |
| **Error** | `ErrorState` composable — emoji + message + retry button |
| **Empty** | `EmptyState` composable — emoji + title + subtitle + optional CTA |
| **Success** | Full content with `LazyColumn` / detail view |
Offline: cached data shown read-only, `OfflineBanner` displayed.
## Sync Strategy
- `SyncManager` implements `DefaultLifecycleObserver` — triggers on `onStart` (app open + foreground)
- Delta sync via `GET /sync?since=<last_timestamp>`
- Server timestamp stored in DataStore, used as `since` on next sync
- Room cache updated; UI observes `Flow<List<Entity>>` — updates automatically
## Setup
1. Set `BASE_URL` in `app/build.gradle` debug/release buildConfigFields
2. Set `GOOGLE_WEB_CLIENT_ID` for Google Sign-In
3. Run backend (`npm run dev` from repo root)
4. `./gradlew assembleDebug`
## Running Tests
```bash
# Unit tests
./gradlew test
# Instrumented tests (requires emulator/device)
./gradlew connectedAndroidTest
```

View File

@@ -1,8 +1,8 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android' id 'com.google.dagger.hilt.android'
id 'com.google.devtools.ksp'
} }
android { android {
@@ -14,45 +14,33 @@ android {
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 1 versionCode 1
versionName "1.0.0" versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
useSupportLibrary true useSupportLibrary true
} }
buildConfigField "String", "BASE_URL", '"https://api.pantree.app/v1/"'
buildConfigField "String", "GOOGLE_WEB_CLIENT_ID", '"YOUR_GOOGLE_WEB_CLIENT_ID"'
} }
buildTypes { buildTypes {
release { release {
minifyEnabled true minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
debug {
buildConfigField "String", "BASE_URL", '"http://10.0.2.2:3000/v1/"'
}
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = '17' jvmTarget = '17'
} }
buildFeatures { buildFeatures {
compose true compose true
buildConfig true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion '1.5.11' kotlinCompilerExtensionVersion '1.5.4'
} }
packaging { packaging {
resources { resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}' excludes += '/META-INF/{AL2.0,LGPL2.1}'
@@ -62,12 +50,12 @@ android {
dependencies { dependencies {
// Core // Core
implementation 'androidx.core:core-ktx:1.13.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.9.0' implementation 'androidx.activity:activity-compose:1.8.2'
// Compose BOM // Compose BOM
implementation platform('androidx.compose:compose-bom:2024.04.01') implementation platform('androidx.compose:compose-bom:2024.01.00')
implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
@@ -75,54 +63,55 @@ dependencies {
implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.compose.material:material-icons-extended'
// Navigation // Navigation
implementation 'androidx.navigation:navigation-compose:2.7.7' 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 // Hilt DI
implementation 'com.google.dagger:hilt-android:2.51.1' implementation 'com.google.dagger:hilt-android:2.50'
ksp 'com.google.dagger:hilt-android-compiler:2.51.1' kapt 'com.google.dagger:hilt-android-compiler:2.50'
implementation 'androidx.hilt:hilt-navigation-compose:1.2.0' implementation 'androidx.hilt:hilt-navigation-compose:1.1.0'
// Room // Room
implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.1' implementation 'androidx.room:room-ktx:2.6.1'
ksp 'androidx.room:room-compiler:2.6.1' kapt 'androidx.room:room-compiler:2.6.1'
// Retrofit + OkHttp // Retrofit + OkHttp
implementation 'com.squareup.retrofit2:retrofit:2.11.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.11.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
// Gson // Security (EncryptedSharedPreferences)
implementation 'com.google.code.gson:gson:2.10.1'
// ViewModel + StateFlow
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0'
// EncryptedSharedPreferences
implementation 'androidx.security:security-crypto:1.1.0-alpha06' implementation 'androidx.security:security-crypto:1.1.0-alpha06'
// Google Identity (One Tap) // Google Sign-In
implementation 'com.google.android.gms:play-services-auth:21.1.1' implementation 'com.google.android.gms:play-services-auth:20.7.0'
// Coil (image loading) // Coil (image loading)
implementation 'io.coil-kt:coil-compose:2.6.0' implementation 'io.coil-kt:coil-compose:2.5.0'
// DataStore
implementation 'androidx.datastore:datastore-preferences:1.1.0'
// Coroutines // Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// DataStore
implementation 'androidx.datastore:datastore-preferences:1.0.0'
// Testing // Testing
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation 'io.mockk:mockk:1.13.10' testImplementation 'io.mockk:mockk:1.13.8'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2024.04.01') androidTestImplementation platform('androidx.compose:compose-bom:2024.01.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4' androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest' debugImplementation 'androidx.compose.ui:ui-test-manifest'
} }
kapt {
correctErrorTypes true
}

View File

@@ -1,43 +0,0 @@
package com.pantree.app.ui.screens.pantry
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.pantree.app.data.local.entity.PantryItemEntity
import com.pantree.app.ui.theme.PantreeTheme
import org.junit.Rule
import org.junit.Test
class PantryScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun emptyState_showsEmptyMessage() {
composeTestRule.setContent {
PantreeTheme {
EmptyState(
emoji = "\uD83E\uDED9",
title = "Your pantry is empty",
subtitle = "Add ingredients you have on hand."
)
}
}
composeTestRule.onNodeWithText("Your pantry is empty").assertIsDisplayed()
}
@Test
fun pantryItemCard_displaysNameAndQuantity() {
val item = PantryItemEntity(
id = "1", itemName = "Flour", quantity = 3,
lastModified = "2024-01-01T00:00:00Z", createdAt = "2024-01-01T00:00:00Z"
)
composeTestRule.setContent {
PantreeTheme {
PantryItemCard(item = item, onEdit = {}, onDelete = {})
}
}
composeTestRule.onNodeWithText("Flour").assertIsDisplayed()
composeTestRule.onNodeWithText("Qty: 3").assertIsDisplayed()
}
}

View File

@@ -8,16 +8,16 @@
android:name=".PantreeApplication" android:name=".PantreeApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="Pantree"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Pantree" android:theme="@style/Theme.Pantree">
android:usesCleartextTraffic="false">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.Pantree"> android:theme="@style/Theme.Pantree.SplashScreen"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -34,6 +34,7 @@
android:pathPrefix="/reset-password" /> android:pathPrefix="/reset-password" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>

View File

@@ -3,23 +3,54 @@ package com.pantree.app
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.pantree.app.ui.navigation.PantreeNavGraph 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.ui.theme.PantreeTheme
import com.pantree.app.util.ConnectivityObserver
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject
lateinit var connectivityObserver: ConnectivityObserver
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { setContent {
PantreeTheme { PantreeTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(
PantreeNavGraph() 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

@@ -2,28 +2,22 @@ package com.pantree.app.data.local
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import com.pantree.app.data.local.dao.PantryDao import com.pantree.app.data.local.dao.*
import com.pantree.app.data.local.dao.RecipeDao import com.pantree.app.data.local.entity.*
import com.pantree.app.data.local.dao.ShoppingListDao
import com.pantree.app.data.local.entity.PantryItemEntity
import com.pantree.app.data.local.entity.RecipeEntity
import com.pantree.app.data.local.entity.RecipeIngredientEntity
import com.pantree.app.data.local.entity.ShoppingListEntity
import com.pantree.app.data.local.entity.ShoppingListItemEntity
@Database( @Database(
entities = [ entities = [
PantryItemEntity::class, PantryItemEntity::class,
RecipeEntity::class,
RecipeIngredientEntity::class,
ShoppingListEntity::class, ShoppingListEntity::class,
ShoppingListItemEntity::class ShoppingListItemEntity::class,
RecipeCacheEntity::class
], ],
version = 1, version = 1,
exportSchema = false exportSchema = false
) )
abstract class PantreeDatabase : RoomDatabase() { abstract class PantreeDatabase : RoomDatabase() {
abstract fun pantryDao(): PantryDao abstract fun pantryDao(): PantryDao
abstract fun recipeDao(): RecipeDao
abstract fun shoppingListDao(): ShoppingListDao abstract fun shoppingListDao(): ShoppingListDao
abstract fun shoppingListItemDao(): ShoppingListItemDao
abstract fun recipeCacheDao(): RecipeCacheDao
} }

View File

@@ -1,32 +0,0 @@
package com.pantree.app.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "sync_prefs")
@Singleton
class SyncPreferences @Inject constructor(
@ApplicationContext private val context: Context
) {
private val LAST_SYNC_KEY = stringPreferencesKey("last_sync_timestamp")
val lastSyncTimestamp: Flow<String> = context.dataStore.data.map { prefs ->
prefs[LAST_SYNC_KEY] ?: "1970-01-01T00:00:00.000Z"
}
suspend fun updateLastSync(timestamp: String) {
context.dataStore.edit { prefs ->
prefs[LAST_SYNC_KEY] = timestamp
}
}
}

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

@@ -1,50 +0,0 @@
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
@Singleton
class TokenStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"pantree_secure_prefs",
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 clearToken() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_EXPIRES_AT)
.apply()
}
fun isLoggedIn(): Boolean = getToken() != null
companion object {
private const val KEY_TOKEN = "jwt_token"
private const val KEY_EXPIRES_AT = "jwt_expires_at"
}
}

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

@@ -1,29 +0,0 @@
package com.pantree.app.data.local.dao
import androidx.room.*
import com.pantree.app.data.local.entity.PantryItemEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PantryDao {
@Query("SELECT * FROM pantry_items ORDER BY item_name ASC")
fun getAllItems(): Flow<List<PantryItemEntity>>
@Query("SELECT * FROM pantry_items WHERE id = :id")
suspend fun getItemById(id: String): PantryItemEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: PantryItemEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<PantryItemEntity>)
@Update
suspend fun updateItem(item: PantryItemEntity)
@Query("DELETE FROM pantry_items WHERE id = :id")
suspend fun deleteItemById(id: String)
@Query("DELETE FROM pantry_items")
suspend fun deleteAll()
}

View File

@@ -1,30 +0,0 @@
package com.pantree.app.data.local.dao
import androidx.room.*
import com.pantree.app.data.local.entity.RecipeEntity
import com.pantree.app.data.local.entity.RecipeIngredientEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface RecipeDao {
@Query("SELECT * FROM recipes ORDER BY name ASC")
fun getAllRecipes(): Flow<List<RecipeEntity>>
@Query("SELECT * FROM recipes WHERE can_make = 1 ORDER BY name ASC")
fun getCanMakeRecipes(): Flow<List<RecipeEntity>>
@Query("SELECT * FROM recipes WHERE can_make = 0 AND available_ingredient_count > 0 ORDER BY availability_percentage DESC")
fun getPartialRecipes(): Flow<List<RecipeEntity>>
@Query("SELECT * FROM recipe_ingredients WHERE recipe_id = :recipeId")
suspend fun getIngredientsForRecipe(recipeId: String): List<RecipeIngredientEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipes(recipes: List<RecipeEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertIngredients(ingredients: List<RecipeIngredientEntity>)
@Query("DELETE FROM recipes")
suspend fun deleteAll()
}

View File

@@ -1,39 +0,0 @@
package com.pantree.app.data.local.dao
import androidx.room.*
import com.pantree.app.data.local.entity.ShoppingListEntity
import com.pantree.app.data.local.entity.ShoppingListItemEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ShoppingListDao {
@Query("SELECT * FROM shopping_lists ORDER BY last_modified DESC")
fun getAllLists(): Flow<List<ShoppingListEntity>>
@Query("SELECT * FROM shopping_list_items WHERE shopping_list_id = :listId ORDER BY item_name ASC")
fun getItemsForList(listId: String): Flow<List<ShoppingListItemEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertList(list: ShoppingListEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLists(lists: List<ShoppingListEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: ShoppingListItemEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<ShoppingListItemEntity>)
@Query("DELETE FROM shopping_lists WHERE id = :id")
suspend fun deleteListById(id: String)
@Query("DELETE FROM shopping_list_items WHERE id = :id")
suspend fun deleteItemById(id: String)
@Query("DELETE FROM shopping_list_items WHERE shopping_list_id = :listId")
suspend fun deleteItemsForList(listId: String)
@Query("DELETE FROM shopping_lists")
suspend fun deleteAll()
}

View File

@@ -2,8 +2,6 @@ package com.pantree.app.data.local.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity(tableName = "pantry_items") @Entity(tableName = "pantry_items")
@@ -15,37 +13,6 @@ data class PantryItemEntity(
@ColumnInfo(name = "created_at") val createdAt: String @ColumnInfo(name = "created_at") val createdAt: String
) )
@Entity(tableName = "recipes")
data class RecipeEntity(
@PrimaryKey val id: String,
val name: String,
val servings: Int,
@ColumnInfo(name = "ingredient_count") val ingredientCount: Int,
@ColumnInfo(name = "available_ingredient_count") val availableIngredientCount: Int,
@ColumnInfo(name = "can_make") val canMake: Boolean,
@ColumnInfo(name = "availability_percentage") val availabilityPercentage: Double
)
@Entity(
tableName = "recipe_ingredients",
foreignKeys = [ForeignKey(
entity = RecipeEntity::class,
parentColumns = ["id"],
childColumns = ["recipe_id"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("recipe_id")]
)
data class RecipeIngredientEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "recipe_id") val recipeId: String,
@ColumnInfo(name = "item_name") val itemName: String,
val quantity: Double,
@ColumnInfo(name = "original_quantity") val originalQuantity: Double,
val unit: String,
@ColumnInfo(name = "in_pantry") val inPantry: Boolean
)
@Entity(tableName = "shopping_lists") @Entity(tableName = "shopping_lists")
data class ShoppingListEntity( data class ShoppingListEntity(
@PrimaryKey val id: String, @PrimaryKey val id: String,
@@ -56,16 +23,7 @@ data class ShoppingListEntity(
@ColumnInfo(name = "created_at") val createdAt: String @ColumnInfo(name = "created_at") val createdAt: String
) )
@Entity( @Entity(tableName = "shopping_list_items")
tableName = "shopping_list_items",
foreignKeys = [ForeignKey(
entity = ShoppingListEntity::class,
parentColumns = ["id"],
childColumns = ["shopping_list_id"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("shopping_list_id")]
)
data class ShoppingListItemEntity( data class ShoppingListItemEntity(
@PrimaryKey val id: String, @PrimaryKey val id: String,
@ColumnInfo(name = "shopping_list_id") val shoppingListId: String, @ColumnInfo(name = "shopping_list_id") val shoppingListId: String,
@@ -75,3 +33,15 @@ data class ShoppingListItemEntity(
@ColumnInfo(name = "checked_off") val checkedOff: Boolean, @ColumnInfo(name = "checked_off") val checkedOff: Boolean,
@ColumnInfo(name = "last_modified") val lastModified: String @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

@@ -2,7 +2,7 @@ package com.pantree.app.data.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
// ── Auth ────────────────────────────────────────────────────────────────────── // ── Auth ────────────────────────────────────────────────────────────────────
data class SignupRequest( data class SignupRequest(
val email: String, val email: String,
@@ -23,7 +23,7 @@ data class PasswordResetRequest(
val email: String val email: String
) )
data class ConfirmPasswordResetRequest( data class PasswordResetConfirmRequest(
val token: String, val token: String,
@SerializedName("new_password") val newPassword: String @SerializedName("new_password") val newPassword: String
) )
@@ -31,8 +31,7 @@ data class ConfirmPasswordResetRequest(
data class AuthResponse( data class AuthResponse(
val user: UserDto, val user: UserDto,
val token: String, val token: String,
@SerializedName("expires_at") val expiresAt: String, @SerializedName("expires_at") val expiresAt: String
@SerializedName("is_new_user") val isNewUser: Boolean? = null
) )
data class UserDto( data class UserDto(
@@ -40,38 +39,23 @@ data class UserDto(
val email: String, val email: String,
val name: String, val name: String,
@SerializedName("profile_picture_url") val profilePictureUrl: String?, @SerializedName("profile_picture_url") val profilePictureUrl: String?,
@SerializedName("email_verified") val emailVerified: Boolean,
@SerializedName("deleted_at") val deletedAt: String?, @SerializedName("deleted_at") val deletedAt: String?,
@SerializedName("created_at") val createdAt: String @SerializedName("created_at") val createdAt: String
) )
data class MessageResponse(
val message: String,
val timestamp: String
)
data class RestoreAccountResponse( data class RestoreAccountResponse(
val user: UserDto, val user: UserDto,
val message: String, val message: String,
val timestamp: String val timestamp: String
) )
data class ApiError( data class MessageResponse(
val error: String, val message: String,
val code: String, val timestamp: String
val timestamp: String,
@SerializedName("deletion_scheduled_at") val deletionScheduledAt: String? = null
) )
// ── Pantry ──────────────────────────────────────────────────────────────────── // ── Pantry ──────────────────────────────────────────────────────────────────
data class AddPantryItemRequest(
@SerializedName("item_name") val itemName: String,
val quantity: Int
)
data class UpdatePantryItemRequest(
val quantity: Int
)
data class PantryItemDto( data class PantryItemDto(
val id: String, val id: String,
@@ -87,29 +71,39 @@ data class PantryListResponse(
) )
data class PantryItemResponse( data class PantryItemResponse(
val item: PantryItemDto, val item: PantryItemDto
@SerializedName("synced_at") val syncedAt: String
) )
// ── Recipes ─────────────────────────────────────────────────────────────────── 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( data class RecipeSummaryDto(
val id: String, val id: String,
val name: String, val name: String,
val servings: Int, val servings: Int,
@SerializedName("ingredient_count") val ingredientCount: Int, @SerializedName("ingredient_count") val ingredientCount: Int,
@SerializedName("available_ingredient_count") val availableIngredientCount: Int, val availability: AvailabilityDto
@SerializedName("can_make") val canMake: Boolean,
@SerializedName("availability_percentage") val availabilityPercentage: Double
) )
data class RecipeIngredientDto( data class AvailabilityDto(
val id: String, val status: String, // "can_make" | "partial" | "missing"
@SerializedName("item_name") val itemName: String, @SerializedName("available_count") val availableCount: Int,
val quantity: Double, @SerializedName("total_count") val totalCount: Int,
@SerializedName("original_quantity") val originalQuantity: Double, @SerializedName("missing_ingredients") val missingIngredients: List<String>
val unit: String, )
@SerializedName("in_pantry") val inPantry: Boolean
data class RecipeListResponse(
val recipes: List<RecipeSummaryDto>,
@SerializedName("synced_at") val syncedAt: String
) )
data class RecipeDetailDto( data class RecipeDetailDto(
@@ -117,53 +111,30 @@ data class RecipeDetailDto(
val name: String, val name: String,
val servings: Int, val servings: Int,
@SerializedName("scaled_servings") val scaledServings: Int, @SerializedName("scaled_servings") val scaledServings: Int,
@SerializedName("scale_factor") val scaleFactor: Int,
val instructions: String, val instructions: String,
val ingredients: List<RecipeIngredientDto>, val ingredients: List<RecipeIngredientDto>,
@SerializedName("can_make") val canMake: Boolean, val availability: AvailabilitySummaryDto
@SerializedName("available_ingredient_count") val availableIngredientCount: Int,
@SerializedName("ingredient_count") val ingredientCount: Int
) )
data class PaginationDto( data class RecipeIngredientDto(
val page: Int, val id: String,
val limit: Int, @SerializedName("item_name") val itemName: String,
val total: Int, val quantity: Double,
@SerializedName("total_pages") val totalPages: Int val unit: String,
@SerializedName("in_pantry") val inPantry: Boolean
) )
data class RecipeListResponse( data class AvailabilitySummaryDto(
val recipes: List<RecipeSummaryDto>, val status: String,
val pagination: PaginationDto, @SerializedName("available_count") val availableCount: Int,
@SerializedName("synced_at") val syncedAt: String @SerializedName("total_count") val totalCount: Int
) )
data class RecipeDetailResponse( data class RecipeDetailResponse(
val recipe: RecipeDetailDto val recipe: RecipeDetailDto
) )
// ── Shopping Lists ─────────────────────────────────────────────────────────── // ── Shopping Lists ───────────────────────────────────────────────────────────
data class CreateShoppingListRequest(
@SerializedName("list_name") val listName: String
)
data class AddShoppingListItemRequest(
@SerializedName("item_name") val itemName: String,
val quantity: Double,
val unit: String
)
data class UpdateShoppingListItemRequest(
val quantity: Double? = null,
val unit: String? = null,
@SerializedName("checked_off") val checkedOff: Boolean? = null
)
data class AddRecipesToListRequest(
@SerializedName("recipe_ids") val recipeIds: List<String>,
@SerializedName("scale_factor") val scaleFactor: Int = 1
)
data class ShoppingListSummaryDto( data class ShoppingListSummaryDto(
val id: String, val id: String,
@@ -174,14 +145,9 @@ data class ShoppingListSummaryDto(
@SerializedName("created_at") val createdAt: String @SerializedName("created_at") val createdAt: String
) )
data class ShoppingListItemDto( data class ShoppingListsResponse(
val id: String, @SerializedName("shopping_lists") val shoppingLists: List<ShoppingListSummaryDto>,
@SerializedName("item_name") val itemName: String, @SerializedName("synced_at") val syncedAt: String
val quantity: Double,
val unit: String,
@SerializedName("checked_off") val checkedOff: Boolean,
@SerializedName("last_modified") val lastModified: String,
val merged: Boolean? = null
) )
data class ShoppingListDetailDto( data class ShoppingListDetailDto(
@@ -192,13 +158,13 @@ data class ShoppingListDetailDto(
val items: List<ShoppingListItemDto> val items: List<ShoppingListItemDto>
) )
data class ShoppingListsResponse( data class ShoppingListItemDto(
@SerializedName("shopping_lists") val shoppingLists: List<ShoppingListSummaryDto>, val id: String,
@SerializedName("synced_at") val syncedAt: String @SerializedName("item_name") val itemName: String,
) val quantity: Double,
val unit: String,
data class ShoppingListResponse( @SerializedName("checked_off") val checkedOff: Boolean,
@SerializedName("shopping_list") val shoppingList: ShoppingListSummaryDto @SerializedName("last_modified") val lastModified: String
) )
data class ShoppingListDetailResponse( data class ShoppingListDetailResponse(
@@ -206,46 +172,93 @@ data class ShoppingListDetailResponse(
@SerializedName("synced_at") val syncedAt: String @SerializedName("synced_at") val syncedAt: String
) )
data class ShoppingListItemResponse( data class CreateShoppingListRequest(
val item: ShoppingListItemDto, @SerializedName("list_name") val listName: String
@SerializedName("synced_at") val syncedAt: String? = null,
val merged: Boolean? = null,
@SerializedName("previous_quantity") val previousQuantity: Double? = null
) )
data class AddRecipesResponse( 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("shopping_list") val shoppingList: ShoppingListDetailDto,
@SerializedName("recipes_added") val recipesAdded: Int, @SerializedName("recipes_added") val recipesAdded: Int,
@SerializedName("items_merged") val itemsMerged: Int, @SerializedName("items_merged") val itemsMerged: Int,
@SerializedName("items_created") val itemsCreated: Int @SerializedName("items_created") val itemsCreated: Int
) )
// ── Sync ────────────────────────────────────────────────────────────────────── data class UpdateShoppingItemRequest(
val quantity: Double? = null,
data class SyncPantryDto( val unit: String? = null,
val updated: List<PantryItemDto>, @SerializedName("checked_off") val checkedOff: Boolean? = null
val deleted: List<String>
) )
data class SyncListItemsDto( data class UpdateShoppingItemResponse(
val updated: List<ShoppingListItemDto>, val item: ShoppingListItemDto
val deleted: List<String> )
// ─── 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( data class SyncShoppingListDto(
val id: String, val id: String,
@SerializedName("list_name") val listName: String, @SerializedName("list_name") val listName: String,
@SerializedName("last_modified") val lastModified: String, @SerializedName("last_modified") val lastModified: String,
val items: SyncListItemsDto val deleted: Boolean,
val items: List<SyncShoppingItemDto>
) )
data class SyncShoppingListsDto( data class SyncShoppingItemDto(
val updated: List<SyncShoppingListDto>, val id: String,
val deleted: List<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
) )
data class SyncResponse( // ─── Error ────────────────────────────────────────────────────────────────────
@SerializedName("server_timestamp") val serverTimestamp: String,
val pantry: SyncPantryDto, data class ApiError(
@SerializedName("shopping_lists") val shoppingLists: SyncShoppingListsDto 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

@@ -1,108 +0,0 @@
package com.pantree.app.data.remote
import com.pantree.app.data.model.*
import retrofit2.Response
import retrofit2.http.*
interface ApiService {
// ── Auth ──────────────────────────────────────────────────────────────────
@POST("auth/signup")
suspend fun signup(@Body request: SignupRequest): Response<AuthResponse>
@POST("auth/signin")
suspend fun signin(@Body request: SigninRequest): Response<AuthResponse>
@POST("auth/google")
suspend fun googleAuth(@Body request: GoogleAuthRequest): Response<AuthResponse>
@POST("auth/password-reset")
suspend fun requestPasswordReset(@Body request: PasswordResetRequest): Response<MessageResponse>
@PUT("auth/password-reset")
suspend fun confirmPasswordReset(@Body request: ConfirmPasswordResetRequest): Response<MessageResponse>
@DELETE("auth/account")
suspend fun deleteAccount(): Response<Unit>
@POST("auth/restore-account")
suspend fun restoreAccount(): Response<RestoreAccountResponse>
// ── Pantry ────────────────────────────────────────────────────────────────
@GET("pantry")
suspend fun getPantryItems(): Response<PantryListResponse>
@POST("pantry")
suspend fun addPantryItem(@Body request: AddPantryItemRequest): Response<PantryItemResponse>
@PUT("pantry/{itemId}")
suspend fun updatePantryItem(
@Path("itemId") itemId: String,
@Body request: UpdatePantryItemRequest
): Response<PantryItemResponse>
@DELETE("pantry/{itemId}")
suspend fun deletePantryItem(@Path("itemId") itemId: String): Response<Unit>
// ── Recipes ───────────────────────────────────────────────────────────────
@GET("recipes")
suspend fun getRecipes(
@Query("filter") filter: String = "all",
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20,
@Query("search") search: String? = null
): Response<RecipeListResponse>
@GET("recipes/{recipeId}")
suspend fun getRecipeById(
@Path("recipeId") recipeId: String,
@Query("scale") scale: Int = 1
): Response<RecipeDetailResponse>
// ── Shopping Lists ────────────────────────────────────────────────────────
@GET("shopping-lists")
suspend fun getShoppingLists(): Response<ShoppingListsResponse>
@POST("shopping-lists")
suspend fun createShoppingList(@Body request: CreateShoppingListRequest): Response<ShoppingListResponse>
@GET("shopping-lists/{listId}")
suspend fun getShoppingListById(@Path("listId") listId: String): Response<ShoppingListDetailResponse>
@DELETE("shopping-lists/{listId}")
suspend fun deleteShoppingList(@Path("listId") listId: String): Response<Unit>
@POST("shopping-lists/{listId}/items")
suspend fun addShoppingListItem(
@Path("listId") listId: String,
@Body request: AddShoppingListItemRequest
): Response<ShoppingListItemResponse>
@POST("shopping-lists/{listId}/add-recipes")
suspend fun addRecipesToShoppingList(
@Path("listId") listId: String,
@Body request: AddRecipesToListRequest
): Response<AddRecipesResponse>
@PUT("shopping-lists/{listId}/items/{itemId}")
suspend fun updateShoppingListItem(
@Path("listId") listId: String,
@Path("itemId") itemId: String,
@Body request: UpdateShoppingListItemRequest
): Response<ShoppingListItemResponse>
@DELETE("shopping-lists/{listId}/items/{itemId}")
suspend fun deleteShoppingListItem(
@Path("listId") listId: String,
@Path("itemId") itemId: String
): Response<Unit>
// ── Sync ──────────────────────────────────────────────────────────────────
@GET("sync")
suspend fun sync(@Query("since") since: String): Response<SyncResponse>
}

View File

@@ -1,15 +1,21 @@
package com.pantree.app.data.remote package com.pantree.app.data.remote
import com.pantree.app.data.local.TokenStore import com.pantree.app.data.local.TokenManager
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import javax.inject.Inject 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( class AuthInterceptor @Inject constructor(
private val tokenStore: TokenStore private val tokenManager: TokenManager
) : Interceptor { ) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenStore.getToken() val token = tokenManager.getToken()
val request = if (token != null) { val request = if (token != null) {
chain.request().newBuilder() chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token") .addHeader("Authorization", "Bearer $token")

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

@@ -1,64 +1,71 @@
package com.pantree.app.data.repository package com.pantree.app.data.repository
import com.google.gson.Gson import com.pantree.app.data.local.TokenManager
import com.pantree.app.data.model.ApiError import com.pantree.app.data.model.*
import com.pantree.app.data.model.AuthResponse import com.pantree.app.data.remote.NetworkResult
import com.pantree.app.data.model.ConfirmPasswordResetRequest import com.pantree.app.data.remote.PantreeApiService
import com.pantree.app.data.model.GoogleAuthRequest import com.pantree.app.data.remote.safeApiCall
import com.pantree.app.data.model.MessageResponse
import com.pantree.app.data.model.PasswordResetRequest
import com.pantree.app.data.model.RestoreAccountResponse
import com.pantree.app.data.model.SigninRequest
import com.pantree.app.data.model.SignupRequest
import com.pantree.app.data.local.TokenStore
import com.pantree.app.data.remote.ApiService
import com.pantree.app.util.Result
import com.pantree.app.util.safeApiCall
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class AuthRepository @Inject constructor( class AuthRepository @Inject constructor(
private val api: ApiService, private val api: PantreeApiService,
private val tokenStore: TokenStore, private val tokenManager: TokenManager
private val gson: Gson
) { ) {
suspend fun signup(email: String, password: String, name: String): Result<AuthResponse> {
val result = safeApiCall(gson) { api.signup(SignupRequest(email, password, name)) } suspend fun signup(email: String, password: String, name: String): NetworkResult<AuthResponse> {
if (result is Result.Success) { val result = safeApiCall { api.signup(SignupRequest(email, password, name)) }
tokenStore.saveToken(result.data.token, result.data.expiresAt) if (result is NetworkResult.Success) {
persistSession(result.data)
} }
return result return result
} }
suspend fun signin(email: String, password: String): Result<AuthResponse> { suspend fun signin(email: String, password: String): NetworkResult<AuthResponse> {
val result = safeApiCall(gson) { api.signin(SigninRequest(email, password)) } val result = safeApiCall { api.signin(SigninRequest(email, password)) }
if (result is Result.Success) { if (result is NetworkResult.Success) {
tokenStore.saveToken(result.data.token, result.data.expiresAt) persistSession(result.data)
} }
return result return result
} }
suspend fun googleAuth(idToken: String): Result<AuthResponse> { suspend fun googleAuth(idToken: String): NetworkResult<AuthResponse> {
val result = safeApiCall(gson) { api.googleAuth(GoogleAuthRequest(idToken)) } val result = safeApiCall { api.googleAuth(GoogleAuthRequest(idToken)) }
if (result is Result.Success) { if (result is NetworkResult.Success) {
tokenStore.saveToken(result.data.token, result.data.expiresAt) persistSession(result.data)
} }
return result return result
} }
suspend fun requestPasswordReset(email: String): Result<MessageResponse> = suspend fun requestPasswordReset(email: String): NetworkResult<MessageResponse> =
safeApiCall(gson) { api.requestPasswordReset(PasswordResetRequest(email)) } safeApiCall { api.requestPasswordReset(PasswordResetRequest(email)) }
suspend fun confirmPasswordReset(token: String, newPassword: String): Result<MessageResponse> = suspend fun confirmPasswordReset(token: String, newPassword: String): NetworkResult<MessageResponse> =
safeApiCall(gson) { api.confirmPasswordReset(ConfirmPasswordResetRequest(token, newPassword)) } safeApiCall { api.confirmPasswordReset(PasswordResetConfirmRequest(token, newPassword)) }
suspend fun deleteAccount(): Result<Unit> = suspend fun deleteAccount(): NetworkResult<Unit> {
safeApiCall(gson) { api.deleteAccount() } val result = safeApiCall { api.deleteAccount() }
if (result is NetworkResult.Success) {
tokenManager.clearAll()
}
return result
}
suspend fun restoreAccount(): Result<RestoreAccountResponse> = suspend fun restoreAccount(): NetworkResult<RestoreAccountResponse> =
safeApiCall(gson) { api.restoreAccount() } safeApiCall { api.restoreAccount() }
fun logout() = tokenStore.clearToken() fun isLoggedIn(): Boolean = tokenManager.isTokenValid()
fun isLoggedIn() = tokenStore.isLoggedIn()
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

@@ -1,63 +1,72 @@
package com.pantree.app.data.repository package com.pantree.app.data.repository
import com.google.gson.Gson
import com.pantree.app.data.local.dao.PantryDao import com.pantree.app.data.local.dao.PantryDao
import com.pantree.app.data.local.entity.PantryItemEntity import com.pantree.app.data.local.entity.PantryItemEntity
import com.pantree.app.data.model.AddPantryItemRequest import com.pantree.app.data.model.*
import com.pantree.app.data.model.PantryItemDto import com.pantree.app.data.remote.NetworkResult
import com.pantree.app.data.model.PantryItemResponse import com.pantree.app.data.remote.PantreeApiService
import com.pantree.app.data.model.PantryListResponse import com.pantree.app.data.remote.safeApiCall
import com.pantree.app.data.model.UpdatePantryItemRequest
import com.pantree.app.data.remote.ApiService
import com.pantree.app.util.Result
import com.pantree.app.util.safeApiCall
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class PantryRepository @Inject constructor( class PantryRepository @Inject constructor(
private val api: ApiService, private val api: PantreeApiService,
private val pantryDao: PantryDao, private val pantryDao: PantryDao
private val gson: Gson
) { ) {
fun getLocalItems(): Flow<List<PantryItemEntity>> = pantryDao.getAllItems()
suspend fun fetchAndCacheItems(): Result<PantryListResponse> { /** Live stream of pantry items from local cache. Always available, even offline. */
val result = safeApiCall(gson) { api.getPantryItems() } fun observePantryItems(): Flow<List<PantryItemEntity>> = pantryDao.observeAll()
if (result is Result.Success) {
/** 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.deleteAll()
pantryDao.insertItems(result.data.items.map { it.toEntity() }) pantryDao.insertAll(entities)
} }
return result return result
} }
suspend fun addItem(name: String, quantity: Int): Result<PantryItemResponse> { suspend fun addItem(itemName: String, quantity: Int): NetworkResult<PantryItemResponse> {
val result = safeApiCall(gson) { api.addPantryItem(AddPantryItemRequest(name, quantity)) } val result = safeApiCall {
if (result is Result.Success) { api.addPantryItem(AddPantryItemRequest(itemName, quantity))
pantryDao.insertItem(result.data.item.toEntity()) }
if (result is NetworkResult.Success) {
pantryDao.insert(result.data.item.toEntity())
} }
return result return result
} }
suspend fun updateItem(id: String, quantity: Int): Result<PantryItemResponse> { suspend fun updateItem(itemId: String, quantity: Int): NetworkResult<PantryItemResponse> {
val result = safeApiCall(gson) { api.updatePantryItem(id, UpdatePantryItemRequest(quantity)) } val result = safeApiCall {
if (result is Result.Success) { api.updatePantryItem(
pantryDao.insertItem(result.data.item.toEntity()) itemId,
UpdatePantryItemRequest(quantity, Instant.now().toString())
)
}
if (result is NetworkResult.Success) {
pantryDao.insert(result.data.item.toEntity())
} }
return result return result
} }
suspend fun deleteItem(id: String): Result<Unit> { suspend fun deleteItem(itemId: String): NetworkResult<Unit> {
val result = safeApiCall(gson) { api.deletePantryItem(id) } val result = safeApiCall { api.deletePantryItem(itemId) }
if (result is Result.Success) { if (result is NetworkResult.Success) {
pantryDao.deleteItemById(id) pantryDao.deleteById(itemId)
} }
return result return result
} }
private fun PantryItemDto.toEntity() = PantryItemEntity( private fun PantryItemDto.toEntity() = PantryItemEntity(
id = id, itemName = itemName, quantity = quantity, id = id,
lastModified = lastModified, createdAt = createdAt itemName = itemName,
quantity = quantity,
lastModified = lastModified,
createdAt = createdAt
) )
} }

View File

@@ -1,50 +1,47 @@
package com.pantree.app.data.repository 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 com.google.gson.Gson
import com.pantree.app.data.local.dao.RecipeDao
import com.pantree.app.data.local.entity.RecipeEntity
import com.pantree.app.data.local.entity.RecipeIngredientEntity
import com.pantree.app.data.model.RecipeDetailResponse
import com.pantree.app.data.model.RecipeListResponse
import com.pantree.app.data.model.RecipeSummaryDto
import com.pantree.app.data.remote.ApiService
import com.pantree.app.util.Result
import com.pantree.app.util.safeApiCall
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class RecipeRepository @Inject constructor( class RecipeRepository @Inject constructor(
private val api: ApiService, private val api: PantreeApiService,
private val recipeDao: RecipeDao, private val recipeCacheDao: RecipeCacheDao
private val gson: Gson
) { ) {
fun getLocalRecipes(): Flow<List<RecipeEntity>> = recipeDao.getAllRecipes() private val gson = Gson()
fun getCanMakeRecipes(): Flow<List<RecipeEntity>> = recipeDao.getCanMakeRecipes()
fun getPartialRecipes(): Flow<List<RecipeEntity>> = recipeDao.getPartialRecipes()
suspend fun fetchRecipes( fun observeRecipes(): Flow<List<RecipeCacheEntity>> = recipeCacheDao.observeAll()
filter: String = "all",
page: Int = 1, suspend fun refreshRecipes(filter: String? = null): NetworkResult<RecipeListResponse> {
search: String? = null val result = safeApiCall { api.getRecipes(filter = filter) }
): Result<RecipeListResponse> { if (result is NetworkResult.Success && filter == null) {
val result = safeApiCall(gson) { api.getRecipes(filter, page, 20, search) } // Only replace full cache on unfiltered fetch
if (result is Result.Success && page == 1) { val entities = result.data.recipes.map { it.toEntity() }
recipeDao.deleteAll() recipeCacheDao.deleteAll()
recipeDao.insertRecipes(result.data.recipes.map { it.toEntity() }) recipeCacheDao.insertAll(entities)
} }
return result return result
} }
suspend fun getRecipeById(id: String, scale: Int = 1): Result<RecipeDetailResponse> = suspend fun getRecipeDetail(recipeId: String, scale: Int? = null): NetworkResult<RecipeDetailResponse> =
safeApiCall(gson) { api.getRecipeById(id, scale) } safeApiCall { api.getRecipeDetail(recipeId, scale) }
private fun RecipeSummaryDto.toEntity() = RecipeEntity( private fun RecipeSummaryDto.toEntity() = RecipeCacheEntity(
id = id, name = name, servings = servings, id = id,
name = name,
servings = servings,
ingredientCount = ingredientCount, ingredientCount = ingredientCount,
availableIngredientCount = availableIngredientCount, availabilityStatus = availability.status,
canMake = canMake, availableCount = availability.availableCount,
availabilityPercentage = availabilityPercentage totalCount = availability.totalCount,
missingIngredientsJson = gson.toJson(availability.missingIngredients)
) )
} }

View File

@@ -1,93 +0,0 @@
package com.pantree.app.data.repository
import com.google.gson.Gson
import com.pantree.app.data.local.dao.ShoppingListDao
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.ApiService
import com.pantree.app.util.Result
import com.pantree.app.util.safeApiCall
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShoppingListRepository @Inject constructor(
private val api: ApiService,
private val shoppingListDao: ShoppingListDao,
private val gson: Gson
) {
fun getLocalLists(): Flow<List<ShoppingListEntity>> = shoppingListDao.getAllLists()
fun getLocalItems(listId: String): Flow<List<ShoppingListItemEntity>> =
shoppingListDao.getItemsForList(listId)
suspend fun fetchLists(): Result<ShoppingListsResponse> {
val result = safeApiCall(gson) { api.getShoppingLists() }
if (result is Result.Success) {
shoppingListDao.deleteAll()
shoppingListDao.insertLists(result.data.shoppingLists.map { it.toEntity() })
}
return result
}
suspend fun createList(name: String): Result<ShoppingListResponse> {
val result = safeApiCall(gson) { api.createShoppingList(CreateShoppingListRequest(name)) }
if (result is Result.Success) {
shoppingListDao.insertList(result.data.shoppingList.toEntity())
}
return result
}
suspend fun fetchListById(listId: String): Result<ShoppingListDetailResponse> {
val result = safeApiCall(gson) { api.getShoppingListById(listId) }
if (result is Result.Success) {
shoppingListDao.deleteItemsForList(listId)
shoppingListDao.insertItems(result.data.shoppingList.items.map { it.toEntity(listId) })
}
return result
}
suspend fun deleteList(listId: String): Result<Unit> {
val result = safeApiCall(gson) { api.deleteShoppingList(listId) }
if (result is Result.Success) shoppingListDao.deleteListById(listId)
return result
}
suspend fun addItem(listId: String, name: String, quantity: Double, unit: String): Result<ShoppingListItemResponse> {
val result = safeApiCall(gson) { api.addShoppingListItem(listId, AddShoppingListItemRequest(name, quantity, unit)) }
if (result is Result.Success) shoppingListDao.insertItem(result.data.item.toEntity(listId))
return result
}
suspend fun addRecipesToList(listId: String, recipeIds: List<String>, scaleFactor: Int): Result<AddRecipesResponse> {
val result = safeApiCall(gson) { api.addRecipesToShoppingList(listId, AddRecipesToListRequest(recipeIds, scaleFactor)) }
if (result is Result.Success) {
shoppingListDao.deleteItemsForList(listId)
shoppingListDao.insertItems(result.data.shoppingList.items.map { it.toEntity(listId) })
}
return result
}
suspend fun updateItem(listId: String, itemId: String, quantity: Double? = null, unit: String? = null, checkedOff: Boolean? = null): Result<ShoppingListItemResponse> {
val result = safeApiCall(gson) { api.updateShoppingListItem(listId, itemId, UpdateShoppingListItemRequest(quantity, unit, checkedOff)) }
if (result is Result.Success) shoppingListDao.insertItem(result.data.item.toEntity(listId))
return result
}
suspend fun deleteItem(listId: String, itemId: String): Result<Unit> {
val result = safeApiCall(gson) { api.deleteShoppingListItem(listId, itemId) }
if (result is Result.Success) shoppingListDao.deleteItemById(itemId)
return result
}
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,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

@@ -1,65 +1,101 @@
package com.pantree.app.data.repository package com.pantree.app.data.repository
import com.google.gson.Gson import com.pantree.app.data.local.TokenManager
import com.pantree.app.data.local.SyncPreferences
import com.pantree.app.data.local.dao.PantryDao import com.pantree.app.data.local.dao.PantryDao
import com.pantree.app.data.local.dao.ShoppingListDao 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.PantryItemEntity
import com.pantree.app.data.local.entity.ShoppingListEntity import com.pantree.app.data.local.entity.ShoppingListEntity
import com.pantree.app.data.local.entity.ShoppingListItemEntity import com.pantree.app.data.local.entity.ShoppingListItemEntity
import com.pantree.app.data.model.SyncResponse import com.pantree.app.data.model.SyncResponse
import com.pantree.app.data.remote.ApiService import com.pantree.app.data.remote.NetworkResult
import com.pantree.app.util.Result import com.pantree.app.data.remote.PantreeApiService
import com.pantree.app.util.safeApiCall import com.pantree.app.data.remote.safeApiCall
import kotlinx.coroutines.flow.first
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class SyncRepository @Inject constructor( class SyncRepository @Inject constructor(
private val api: ApiService, private val api: PantreeApiService,
private val tokenManager: TokenManager,
private val pantryDao: PantryDao, private val pantryDao: PantryDao,
private val shoppingListDao: ShoppingListDao, private val shoppingListDao: ShoppingListDao,
private val syncPreferences: SyncPreferences, private val shoppingListItemDao: ShoppingListItemDao
private val gson: Gson
) { ) {
suspend fun sync(): Result<SyncResponse> {
val since = syncPreferences.lastSyncTimestamp.first()
val result = safeApiCall(gson) { api.sync(since) }
if (result is Result.Success) {
val data = result.data
// Apply pantry delta
data.pantry.updated.forEach {
pantryDao.insertItem(PantryItemEntity(it.id, it.itemName, it.quantity, it.lastModified, it.createdAt))
}
data.pantry.deleted.forEach { pantryDao.deleteItemById(it) }
// Apply shopping list delta /**
data.shoppingLists.updated.forEach { list -> * Performs a full or delta sync depending on whether a last-sync timestamp exists.
shoppingListDao.insertList( * On full sync: replaces all local data.
ShoppingListEntity( * On delta sync: applies only changed/deleted records.
id = list.id, listName = list.listName, */
itemCount = list.items.updated.size, suspend fun sync(): NetworkResult<SyncResponse> {
checkedCount = list.items.updated.count { it.checkedOff }, val since = tokenManager.getLastSyncTimestamp()
lastModified = list.lastModified, createdAt = list.lastModified 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
) )
list.items.updated.forEach { item -> })
shoppingListDao.insertItem( toDelete.forEach { pantryDao.deleteById(it.id) }
ShoppingListItemEntity(
id = item.id, shoppingListId = list.id, // Apply shopping list changes
itemName = item.itemName, quantity = item.quantity, for (list in data.shoppingLists) {
unit = item.unit, checkedOff = item.checkedOff, if (list.deleted) {
lastModified = item.lastModified 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 }
list.items.deleted.forEach { shoppingListDao.deleteItemById(it) } val itemsToDelete = list.items.filter { it.deleted }
}
data.shoppingLists.deleted.forEach { shoppingListDao.deleteListById(it) }
syncPreferences.updateLastSync(data.serverTimestamp) 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 return result
} }
} }

View File

@@ -2,16 +2,11 @@ package com.pantree.app.di
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.pantree.app.BuildConfig
import com.pantree.app.data.local.PantreeDatabase import com.pantree.app.data.local.PantreeDatabase
import com.pantree.app.data.local.TokenStore import com.pantree.app.data.local.TokenManager
import com.pantree.app.data.local.dao.PantryDao import com.pantree.app.data.local.dao.*
import com.pantree.app.data.local.dao.RecipeDao
import com.pantree.app.data.local.dao.ShoppingListDao
import com.pantree.app.data.remote.ApiService
import com.pantree.app.data.remote.AuthInterceptor import com.pantree.app.data.remote.AuthInterceptor
import com.pantree.app.data.remote.PantreeApiService
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -28,16 +23,13 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppModule { object AppModule {
@Provides // ─── Network ─────────────────────────────────────────────────────────────
@Singleton
fun provideGson(): Gson = GsonBuilder().create()
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
} }
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
@@ -50,26 +42,38 @@ object AppModule {
@Provides @Provides
@Singleton @Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit = fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
Retrofit.Builder() Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL) .baseUrl("https://api.pantree.app/")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
@Provides @Provides
@Singleton @Singleton
fun provideApiService(retrofit: Retrofit): ApiService = fun providePantreeApiService(retrofit: Retrofit): PantreeApiService =
retrofit.create(ApiService::class.java) retrofit.create(PantreeApiService::class.java)
// ─── Database ─────────────────────────────────────────────────────────────
@Provides @Provides
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): PantreeDatabase = fun providePantreeDatabase(@ApplicationContext context: Context): PantreeDatabase =
Room.databaseBuilder(context, PantreeDatabase::class.java, "pantree.db") Room.databaseBuilder(
.fallbackToDestructiveMigration() context,
.build() PantreeDatabase::class.java,
"pantree.db"
).fallbackToDestructiveMigration().build()
@Provides fun providePantryDao(db: PantreeDatabase): PantryDao = db.pantryDao() @Provides
@Provides fun provideRecipeDao(db: PantreeDatabase): RecipeDao = db.recipeDao() fun providePantryDao(db: PantreeDatabase): PantryDao = db.pantryDao()
@Provides fun provideShoppingListDao(db: PantreeDatabase): ShoppingListDao = db.shoppingListDao()
@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

@@ -1,41 +0,0 @@
package com.pantree.app.sync
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.pantree.app.data.local.TokenStore
import com.pantree.app.data.repository.SyncRepository
import com.pantree.app.util.Result
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SyncManager @Inject constructor(
private val syncRepository: SyncRepository,
private val tokenStore: TokenStore,
@ApplicationContext private val context: Context
) : DefaultLifecycleObserver {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Called on app open (cold start) and foreground return. */
override fun onStart(owner: LifecycleOwner) {
triggerSync()
}
fun triggerSync() {
if (!tokenStore.isLoggedIn()) return
scope.launch {
when (val result = syncRepository.sync()) {
is Result.Success -> { /* Delta applied to Room, UI observes Flow */ }
is Result.Error -> { /* Log silently; cached data still shown */ }
is Result.NetworkError -> { /* Offline — cached data shown, offline banner visible */ }
}
}
}
}

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

@@ -1,17 +1,28 @@
package com.pantree.app.ui.components package com.pantree.app.ui.components
import androidx.compose.animation.*
import androidx.compose.foundation.layout.* 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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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 @Composable
fun LoadingState( fun LoadingState(
modifier: Modifier = Modifier, message: String = "Loading…",
message: String = "Loading..." modifier: Modifier = Modifier
) { ) {
Box( Box(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
@@ -21,16 +32,38 @@ fun LoadingState(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp,
modifier = Modifier.size(40.dp)
)
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) 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 @Composable
fun ErrorState( fun ErrorState(
message: String, message: String,
@@ -38,25 +71,41 @@ fun ErrorState(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
modifier = modifier.fillMaxSize().padding(32.dp), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) 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(
text = "\uD83D\uDE15", text = "Something went wrong",
style = MaterialTheme.typography.headlineLarge style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
) )
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurface textAlign = TextAlign.Center
) )
if (onRetry != null) { if (onRetry != null) {
Spacer(modifier = Modifier.height(4.dp))
Button(onClick = onRetry) { Button(onClick = onRetry) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Try again") Text("Try again")
} }
} }
@@ -64,9 +113,14 @@ fun ErrorState(
} }
} }
// ─────────────────────────────────────────────────────────────────────────────
// EMPTY STATE
// The pantry is empty. The list is empty. That's fine — tell them what to do.
// ─────────────────────────────────────────────────────────────────────────────
@Composable @Composable
fun EmptyState( fun EmptyState(
emoji: String, icon: ImageVector,
title: String, title: String,
subtitle: String, subtitle: String,
actionLabel: String? = null, actionLabel: String? = null,
@@ -74,28 +128,41 @@ fun EmptyState(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
modifier = modifier.fillMaxSize().padding(32.dp), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(32.dp)
) { ) {
Text(text = emoji, style = MaterialTheme.typography.headlineLarge) Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(64.dp)
)
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) textAlign = TextAlign.Center
) )
if (actionLabel != null && onAction != null) { if (actionLabel != null && onAction != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(4.dp))
Button(onClick = onAction) { Button(onClick = onAction) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(actionLabel) Text(actionLabel)
} }
} }
@@ -103,45 +170,170 @@ fun EmptyState(
} }
} }
// ─────────────────────────────────────────────────────────────────────────────
// OFFLINE BANNER
// Shown at the top of screens when there's no connection.
// Read-only mode — no edits while offline.
// ─────────────────────────────────────────────────────────────────────────────
@Composable @Composable
fun OfflineBanner(modifier: Modifier = Modifier) { fun OfflineBanner(modifier: Modifier = Modifier) {
Surface( Surface(
modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.errorContainer,
color = MaterialTheme.colorScheme.secondaryContainer modifier = modifier.fillMaxWidth()
) { ) {
Text( Row(
text = "\uD83D\uDCF5 You're offline. Showing cached data.", modifier = Modifier
style = MaterialTheme.typography.bodySmall, .padding(horizontal = 16.dp, vertical = 10.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.colorScheme.onSecondaryContainer 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 @Composable
fun PantreeTopBar( fun SyncingIndicator(
title: String, isSyncing: Boolean,
onNavigateBack: (() -> Unit)? = null, modifier: Modifier = Modifier
actions: @Composable RowScope.() -> Unit = {}
) { ) {
TopAppBar( AnimatedVisibility(
title = { Text(title, style = MaterialTheme.typography.titleLarge) }, visible = isSyncing,
navigationIcon = { enter = fadeIn() + slideInVertically(),
if (onNavigateBack != null) { exit = fadeOut() + slideOutVertically(),
IconButton(onClick = onNavigateBack) { modifier = modifier
Icon( ) {
imageVector = androidx.compose.material.icons.Icons.Default.ArrowBack, Surface(
contentDescription = "Back" 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
)
} }
}, }
actions = actions, }
colors = TopAppBarDefaults.topAppBarColors( }
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary, // ─────────────────────────────────────────────────────────────────────────────
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, // SNACKBAR HOST — used for transient feedback
actionIconContentColor = MaterialTheme.colorScheme.onPrimary // ─────────────────────────────────────────────────────────────────────────────
)
@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

@@ -1,120 +0,0 @@
package com.pantree.app.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import com.pantree.app.ui.screens.auth.ForgotPasswordScreen
import com.pantree.app.ui.screens.auth.ResetPasswordScreen
import com.pantree.app.ui.screens.auth.SignInScreen
import com.pantree.app.ui.screens.auth.SignUpScreen
import com.pantree.app.ui.screens.auth.SplashScreen
import com.pantree.app.ui.screens.pantry.PantryScreen
import com.pantree.app.ui.screens.recipe.RecipeDetailScreen
import com.pantree.app.ui.screens.recipe.RecipesScreen
import com.pantree.app.ui.screens.settings.SettingsScreen
import com.pantree.app.ui.screens.shopping.ShoppingListDetailScreen
import com.pantree.app.ui.screens.shopping.ShoppingListsScreen
@Composable
fun PantreeNavGraph() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.Splash.route) {
composable(Screen.Splash.route) {
SplashScreen(
onNavigateToSignIn = { navController.navigate(Screen.SignIn.route) { popUpTo(Screen.Splash.route) { inclusive = true } } },
onNavigateToPantry = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.Splash.route) { inclusive = true } } }
)
}
composable(Screen.SignIn.route) {
SignInScreen(
onSignInSuccess = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.SignIn.route) { inclusive = true } } },
onNavigateToSignUp = { navController.navigate(Screen.SignUp.route) },
onNavigateToForgotPassword = { navController.navigate(Screen.ForgotPassword.route) }
)
}
composable(Screen.SignUp.route) {
SignUpScreen(
onSignUpSuccess = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.SignIn.route) { inclusive = true } } },
onNavigateBack = { navController.popBackStack() }
)
}
composable(Screen.ForgotPassword.route) {
ForgotPasswordScreen(onNavigateBack = { navController.popBackStack() })
}
composable(
route = Screen.ResetPassword.route,
arguments = listOf(navArgument("token") { type = NavType.StringType }),
deepLinks = listOf(navDeepLink { uriPattern = "https://pantree.app/reset-password?token={token}" })
) { backStackEntry ->
ResetPasswordScreen(
token = backStackEntry.arguments?.getString("token") ?: "",
onResetSuccess = { navController.navigate(Screen.SignIn.route) { popUpTo(0) { inclusive = true } } }
)
}
composable(Screen.Pantry.route) {
PantryScreen(
onNavigateToRecipes = { navController.navigate(Screen.Recipes.route) },
onNavigateToShoppingLists = { navController.navigate(Screen.ShoppingLists.route) },
onNavigateToSettings = { navController.navigate(Screen.Settings.route) }
)
}
composable(Screen.Recipes.route) {
RecipesScreen(
onRecipeClick = { id -> navController.navigate(Screen.RecipeDetail.createRoute(id)) },
onNavigateBack = { navController.popBackStack() }
)
}
composable(
route = Screen.RecipeDetail.route,
arguments = listOf(navArgument("recipeId") { type = NavType.StringType })
) { backStackEntry ->
RecipeDetailScreen(
recipeId = backStackEntry.arguments?.getString("recipeId") ?: "",
onNavigateBack = { navController.popBackStack() },
onAddToList = { listId -> navController.navigate(Screen.ShoppingListDetail.createRoute(listId)) }
)
}
composable(Screen.ShoppingLists.route) {
ShoppingListsScreen(
onListClick = { id -> navController.navigate(Screen.ShoppingListDetail.createRoute(id)) },
onNavigateBack = { navController.popBackStack() }
)
}
composable(
route = Screen.ShoppingListDetail.route,
arguments = listOf(navArgument("listId") { type = NavType.StringType })
) { backStackEntry ->
ShoppingListDetailScreen(
listId = backStackEntry.arguments?.getString("listId") ?: "",
onNavigateBack = { navController.popBackStack() },
onNavigateToRecipes = { navController.navigate(Screen.Recipes.route) }
)
}
composable(Screen.Settings.route) {
SettingsScreen(
onSignOut = { navController.navigate(Screen.SignIn.route) { popUpTo(0) { inclusive = true } } },
onNavigateBack = { navController.popBackStack() }
)
}
}
}

View File

@@ -1,24 +1,28 @@
package com.pantree.app.ui.navigation 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) { sealed class Screen(val route: String) {
// Auth // Auth
object Splash : Screen("splash") object SignIn : Screen("sign_in")
object SignIn : Screen("signin") object SignUp : Screen("sign_up")
object SignUp : Screen("signup")
object ForgotPassword : Screen("forgot_password") object ForgotPassword : Screen("forgot_password")
object ResetPassword : Screen("reset_password/{token}") { object AccountRestore : Screen("account_restore/{deletion_scheduled_at}") {
fun createRoute(token: String) = "reset_password/$token" fun createRoute(deletionScheduledAt: String) =
"account_restore/${java.net.URLEncoder.encode(deletionScheduledAt, "UTF-8")}"
} }
// Main // Main (bottom nav)
object Pantry : Screen("pantry") object Pantry : Screen("pantry")
object Recipes : Screen("recipes") object Recipes : Screen("recipes")
object RecipeDetail : Screen("recipe/{recipeId}") { object RecipeDetail : Screen("recipe_detail/{recipe_id}") {
fun createRoute(id: String) = "recipe/$id" fun createRoute(recipeId: String) = "recipe_detail/$recipeId"
} }
object ShoppingLists : Screen("shopping_lists") object ShoppingLists : Screen("shopping_lists")
object ShoppingListDetail : Screen("shopping_list/{listId}") { object ShoppingListDetail : Screen("shopping_list_detail/{list_id}/{list_name}") {
fun createRoute(id: String) = "shopping_list/$id" fun createRoute(listId: String, listName: String) =
"shopping_list_detail/$listId/${java.net.URLEncoder.encode(listName, "UTF-8")}"
} }
object Settings : Screen("settings") 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

@@ -1,93 +0,0 @@
package com.pantree.app.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pantree.app.data.repository.AuthRepository
import com.pantree.app.util.Result
import com.pantree.app.util.toUserMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AuthUiState(
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false,
val pendingDeletionDate: String? = null
)
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
fun signup(email: String, password: String, name: String) {
viewModelScope.launch {
_uiState.value = AuthUiState(isLoading = true)
when (val result = authRepository.signup(email, password, name)) {
is Result.Success -> _uiState.value = AuthUiState(success = true)
is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage())
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
}
}
}
fun signin(email: String, password: String) {
viewModelScope.launch {
_uiState.value = AuthUiState(isLoading = true)
when (val result = authRepository.signin(email, password)) {
is Result.Success -> _uiState.value = AuthUiState(success = true)
is Result.Error -> {
val pendingDate = if (result.code == "ACCOUNT_PENDING_DELETION") result.message else null
_uiState.value = AuthUiState(error = result.toUserMessage(), pendingDeletionDate = pendingDate)
}
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
}
}
}
fun googleAuth(idToken: String) {
viewModelScope.launch {
_uiState.value = AuthUiState(isLoading = true)
when (val result = authRepository.googleAuth(idToken)) {
is Result.Success -> _uiState.value = AuthUiState(success = true)
is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage())
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
}
}
}
fun requestPasswordReset(email: String) {
viewModelScope.launch {
_uiState.value = AuthUiState(isLoading = true)
when (authRepository.requestPasswordReset(email)) {
is Result.Success -> _uiState.value = AuthUiState(success = true)
is Result.Error -> _uiState.value = AuthUiState(success = true) // Always show success (anti-enumeration)
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
}
}
}
fun confirmPasswordReset(token: String, newPassword: String) {
viewModelScope.launch {
_uiState.value = AuthUiState(isLoading = true)
when (val result = authRepository.confirmPasswordReset(token, newPassword)) {
is Result.Success -> _uiState.value = AuthUiState(success = true)
is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage())
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
fun isLoggedIn() = authRepository.isLoggedIn()
}

View File

@@ -1,153 +0,0 @@
package com.pantree.app.ui.screens.auth
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun ForgotPasswordScreen(
onNavigateBack: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var email by remember { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Reset password") },
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back") } }
)
}
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (uiState.success) {
// Success state
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("📬", style = MaterialTheme.typography.headlineLarge)
Text("Check your inbox", style = MaterialTheme.typography.titleLarge)
Text(
"If an account exists for $email, we've sent a reset link. It expires in 1 hour.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = onNavigateBack) { Text("Back to sign in") }
}
}
} else {
Text("Enter your email and we'll send you a reset link.", style = MaterialTheme.typography.bodyMedium)
if (uiState.error != null) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth()
) {
Text(uiState.error!!, modifier = Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
}
}
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { viewModel.requestPasswordReset(email.trim()) },
enabled = !uiState.isLoading && email.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Send reset link")
}
}
}
}
}
}
@Composable
fun ResetPasswordScreen(
token: String,
onResetSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
val passwordsMatch = newPassword == confirmPassword && newPassword.isNotEmpty()
LaunchedEffect(uiState.success) {
if (uiState.success) onResetSuccess()
}
Scaffold(topBar = { TopAppBar(title = { Text("New password") }) }) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Choose a new password for your account.", style = MaterialTheme.typography.bodyMedium)
if (uiState.error != null) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth()
) {
Text(uiState.error!!, modifier = Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
}
}
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it; viewModel.clearError() },
label = { Text("New password") },
supportingText = { Text("At least 8 characters") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text("Confirm password") },
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
supportingText = { if (confirmPassword.isNotEmpty() && !passwordsMatch) Text("Passwords don't match") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { viewModel.confirmPasswordReset(token, newPassword) },
enabled = !uiState.isLoading && passwordsMatch && newPassword.length >= 8,
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Update password")
}
}
}
}
}

View File

@@ -1,133 +0,0 @@
package com.pantree.app.ui.screens.auth
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun SignInScreen(
onSignInSuccess: () -> Unit,
onNavigateToSignUp: () -> Unit,
onNavigateToForgotPassword: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
LaunchedEffect(uiState.success) {
if (uiState.success) onSignInSuccess()
}
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(Modifier.height(48.dp))
Text("🌿", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(8.dp))
Text("Welcome back", style = MaterialTheme.typography.headlineMedium)
Text(
"Sign in to your pantry",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(Modifier.height(32.dp))
// Error banner
if (uiState.error != null) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = uiState.error!!,
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(Modifier.height(16.dp))
}
OutlinedTextField(
value = email,
onValueChange = { email = it; viewModel.clearError() },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it; viewModel.clearError() },
label = { Text("Password") },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (passwordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth()
)
TextButton(
onClick = onNavigateToForgotPassword,
modifier = Modifier.align(Alignment.End)
) {
Text("Forgot password?")
}
Spacer(Modifier.height(8.dp))
Button(
onClick = { viewModel.signin(email.trim(), password) },
enabled = !uiState.isLoading && email.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Sign in")
}
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(" or ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
HorizontalDivider(modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Don't have an account?", style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = onNavigateToSignUp) { Text("Sign up") }
}
Spacer(Modifier.height(48.dp))
}
}
}

View File

@@ -1,124 +0,0 @@
package com.pantree.app.ui.screens.auth
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun SignUpScreen(
onSignUpSuccess: () -> Unit,
onNavigateBack: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
LaunchedEffect(uiState.success) {
if (uiState.success) onSignUpSuccess()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Create account") },
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back") } }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Spacer(Modifier.height(16.dp))
Text("Let's get you set up", style = MaterialTheme.typography.headlineSmall)
Text(
"Your pantry awaits.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(Modifier.height(8.dp))
if (uiState.error != null) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = uiState.error!!,
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium
)
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it; viewModel.clearError() },
label = { Text("Full name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = email,
onValueChange = { email = it; viewModel.clearError() },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = { password = it; viewModel.clearError() },
label = { Text("Password") },
supportingText = { Text("At least 8 characters") },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null
)
}
},
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { viewModel.signup(email.trim(), password, name.trim()) },
enabled = !uiState.isLoading && name.isNotBlank() && email.isNotBlank() && password.length >= 8,
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Create account")
}
}
}
}
}

View File

@@ -1,31 +0,0 @@
package com.pantree.app.ui.screens.auth
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun SplashScreen(
onNavigateToSignIn: () -> Unit,
onNavigateToPantry: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val isLoggedIn = remember { viewModel.isLoggedIn() }
LaunchedEffect(Unit) {
if (isLoggedIn) onNavigateToPantry() else onNavigateToSignIn()
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("🌿", style = MaterialTheme.typography.headlineLarge)
Text("Pantree", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.primary)
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}

View File

@@ -1,253 +0,0 @@
package com.pantree.app.ui.screens.pantry
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pantree.app.data.local.entity.PantryItemEntity
import com.pantree.app.ui.components.EmptyState
import com.pantree.app.ui.components.ErrorState
import com.pantree.app.ui.components.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantryScreen(
onNavigateToRecipes: () -> Unit,
onNavigateToShoppingLists: () -> Unit,
onNavigateToSettings: () -> Unit,
viewModel: PantryViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) }
var editingItem by remember { mutableStateOf<PantryItemEntity?>(null) }
var deletingItem by remember { mutableStateOf<PantryItemEntity?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.actionSuccess) {
uiState.actionSuccess?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearActionMessages()
}
}
LaunchedEffect(uiState.actionError) {
uiState.actionError?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearActionMessages()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("My Pantry") },
actions = {
IconButton(onClick = onNavigateToRecipes) {
Icon(Icons.Default.MenuBook, contentDescription = "Recipes", tint = MaterialTheme.colorScheme.onPrimary)
}
IconButton(onClick = onNavigateToShoppingLists) {
Icon(Icons.Default.ShoppingCart, contentDescription = "Shopping Lists", tint = MaterialTheme.colorScheme.onPrimary)
}
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings", tint = MaterialTheme.colorScheme.onPrimary)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showAddDialog = true }) {
Icon(Icons.Default.Add, contentDescription = "Add item")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
when {
uiState.isLoading && uiState.items.isEmpty() -> LoadingState(message = "Loading your pantry...")
uiState.error != null && uiState.items.isEmpty() -> ErrorState(
message = uiState.error!!,
onRetry = { viewModel.refresh() }
)
uiState.items.isEmpty() -> EmptyState(
emoji = "\uD83E\uDED9",
title = "Your pantry is empty",
subtitle = "Add ingredients you have on hand and we'll tell you what you can cook.",
actionLabel = "Add your first item",
onAction = { showAddDialog = true }
)
else -> {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
Text(
"${uiState.items.size} item${if (uiState.items.size != 1) "s" else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 4.dp)
)
}
items(uiState.items, key = { it.id }) { item ->
PantryItemCard(
item = item,
onEdit = { editingItem = item },
onDelete = { deletingItem = item }
)
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
}
if (showAddDialog) {
AddPantryItemDialog(
onDismiss = { showAddDialog = false },
onConfirm = { name, qty ->
viewModel.addItem(name, qty)
showAddDialog = false
}
)
}
editingItem?.let { item ->
EditPantryItemDialog(
item = item,
onDismiss = { editingItem = null },
onConfirm = { qty ->
viewModel.updateItem(item.id, qty)
editingItem = null
}
)
}
deletingItem?.let { item ->
AlertDialog(
onDismissRequest = { deletingItem = null },
title = { Text("Remove item?") },
text = { Text("Remove \"${item.itemName}\" from your pantry?") },
confirmButton = {
TextButton(onClick = { viewModel.deleteItem(item.id); deletingItem = null }) {
Text("Remove", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { deletingItem = null }) { Text("Cancel") } }
)
}
}
@Composable
fun PantryItemCard(
item: PantryItemEntity,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(item.itemName, style = MaterialTheme.typography.titleMedium)
Text(
"Qty: ${item.quantity}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
fun AddPantryItemDialog(onDismiss: () -> Unit, onConfirm: (String, Int) -> Unit) {
var name by remember { mutableStateOf("") }
var quantityText by remember { mutableStateOf("1") }
val quantity = quantityText.toIntOrNull()
val isValid = name.isNotBlank() && quantity != null && quantity > 0
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add to pantry") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Item name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = quantityText,
onValueChange = { quantityText = it },
label = { Text("Quantity") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
isError = quantityText.isNotEmpty() && quantity == null,
supportingText = { if (quantityText.isNotEmpty() && quantity == null) Text("Must be a whole number") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(onClick = { if (isValid) onConfirm(name, quantity!!) }, enabled = isValid) {
Text("Add")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
@Composable
fun EditPantryItemDialog(item: PantryItemEntity, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) {
var quantityText by remember { mutableStateOf(item.quantity.toString()) }
val quantity = quantityText.toIntOrNull()
val isValid = quantity != null && quantity > 0
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit quantity") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(item.itemName, style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = quantityText,
onValueChange = { quantityText = it },
label = { Text("Quantity") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
isError = quantityText.isNotEmpty() && !isValid,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(onClick = { if (isValid) onConfirm(quantity!!) }, enabled = isValid) {
Text("Save")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}

View File

@@ -1,84 +0,0 @@
package com.pantree.app.ui.screens.pantry
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pantree.app.data.local.entity.PantryItemEntity
import com.pantree.app.data.repository.PantryRepository
import com.pantree.app.util.Result
import com.pantree.app.util.toUserMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PantryUiState(
val isLoading: Boolean = false,
val items: List<PantryItemEntity> = emptyList(),
val error: String? = null,
val actionError: String? = null,
val actionSuccess: String? = null
)
@HiltViewModel
class PantryViewModel @Inject constructor(
private val pantryRepository: PantryRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(PantryUiState(isLoading = true))
val uiState: StateFlow<PantryUiState> = _uiState.asStateFlow()
init {
// Observe local cache immediately
viewModelScope.launch {
pantryRepository.getLocalItems().collect { items ->
_uiState.update { it.copy(items = items, isLoading = false) }
}
}
refresh()
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
when (val result = pantryRepository.fetchAndCacheItems()) {
is Result.Success -> _uiState.update { it.copy(isLoading = false) }
is Result.Error -> _uiState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
is Result.NetworkError -> _uiState.update { it.copy(isLoading = false, error = null) } // Show cached, offline banner handled elsewhere
}
}
}
fun addItem(name: String, quantity: Int) {
viewModelScope.launch {
when (val result = pantryRepository.addItem(name.trim(), quantity)) {
is Result.Success -> _uiState.update { it.copy(actionSuccess = "${result.data.item.itemName} added to pantry.", actionError = null) }
is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) }
is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") }
}
}
}
fun updateItem(id: String, quantity: Int) {
viewModelScope.launch {
when (val result = pantryRepository.updateItem(id, quantity)) {
is Result.Success -> _uiState.update { it.copy(actionSuccess = "Updated.", actionError = null) }
is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) }
is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") }
}
}
}
fun deleteItem(id: String) {
viewModelScope.launch {
when (val result = pantryRepository.deleteItem(id)) {
is Result.Success -> _uiState.update { it.copy(actionSuccess = "Item removed.", actionError = null) }
is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) }
is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") }
}
}
}
fun clearActionMessages() {
_uiState.update { it.copy(actionError = null, actionSuccess = null) }
}
}

View File

@@ -1,187 +0,0 @@
package com.pantree.app.ui.screens.recipe
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pantree.app.data.model.RecipeIngredientDto
import com.pantree.app.ui.components.ErrorState
import com.pantree.app.ui.components.LoadingState
import com.pantree.app.ui.theme.SuccessGreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecipeDetailScreen(
recipeId: String,
onNavigateBack: () -> Unit,
onAddToList: (String) -> Unit,
viewModel: RecipeViewModel = hiltViewModel()
) {
val uiState by viewModel.detailState.collectAsStateWithLifecycle()
LaunchedEffect(recipeId) {
viewModel.loadRecipeDetail(recipeId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.recipe?.name ?: "Recipe") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { padding ->
when {
uiState.isLoading -> LoadingState(modifier = Modifier.padding(padding), message = "Loading recipe...")
uiState.error != null -> ErrorState(
modifier = Modifier.padding(padding),
message = uiState.error!!,
onRetry = { viewModel.loadRecipeDetail(recipeId) }
)
uiState.recipe != null -> {
val recipe = uiState.recipe!!
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(recipe.name, style = MaterialTheme.typography.headlineSmall)
Text(
"${recipe.scaledServings} servings",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
if (recipe.canMake) {
Surface(
color = SuccessGreen.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small
) {
Text(
"\u2705 Ready to cook",
style = MaterialTheme.typography.labelLarge,
color = SuccessGreen,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
// Scale selector
item {
Card {
Column(modifier = Modifier.padding(16.dp)) {
Text("Scale recipe", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(1, 2, 3).forEach { scale ->
FilterChip(
selected = uiState.scaleFactor == scale,
onClick = { viewModel.setScale(recipeId, scale) },
label = { Text("${scale}x") }
)
}
}
}
}
}
// Ingredients
item {
Text(
"Ingredients (${recipe.availableIngredientCount}/${recipe.ingredientCount} in pantry)",
style = MaterialTheme.typography.titleMedium
)
}
items(recipe.ingredients) { ingredient ->
IngredientRow(ingredient = ingredient)
}
// Instructions
item {
Text("Instructions", style = MaterialTheme.typography.titleMedium)
}
item {
Card {
Text(
recipe.instructions,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp)
)
}
}
// Add to shopping list button
item {
Spacer(Modifier.height(8.dp))
Button(
onClick = { /* TODO: show list picker dialog */ },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.ShoppingCart, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Add to shopping list")
}
Spacer(Modifier.height(16.dp))
}
}
}
}
}
}
@Composable
fun IngredientRow(ingredient: RecipeIngredientDto) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (ingredient.inPantry) SuccessGreen.copy(alpha = 0.07f)
else MaterialTheme.colorScheme.surface
)
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
if (ingredient.inPantry) "\u2705" else "\u274C",
modifier = Modifier.width(28.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(ingredient.itemName, style = MaterialTheme.typography.bodyLarge)
}
Text(
"${formatQuantity(ingredient.quantity)} ${ingredient.unit}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
fun formatQuantity(qty: Double): String {
return if (qty == qty.toLong().toDouble()) qty.toLong().toString()
else String.format("%.2f", qty).trimEnd('0').trimEnd('.')
}

View File

@@ -1,83 +0,0 @@
package com.pantree.app.ui.screens.recipe
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pantree.app.data.local.entity.RecipeEntity
import com.pantree.app.data.model.RecipeDetailDto
import com.pantree.app.data.repository.RecipeRepository
import com.pantree.app.util.Result
import com.pantree.app.util.toUserMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class RecipesUiState(
val isLoading: Boolean = false,
val recipes: List<RecipeEntity> = emptyList(),
val error: String? = null,
val currentFilter: String = "all",
val searchQuery: String = "",
val currentPage: Int = 1,
val totalPages: Int = 1
)
data class RecipeDetailUiState(
val isLoading: Boolean = false,
val recipe: RecipeDetailDto? = null,
val error: String? = null,
val scaleFactor: Int = 1
)
@HiltViewModel
class RecipeViewModel @Inject constructor(
private val recipeRepository: RecipeRepository
) : ViewModel() {
private val _listState = MutableStateFlow(RecipesUiState(isLoading = true))
val listState: StateFlow<RecipesUiState> = _listState.asStateFlow()
private val _detailState = MutableStateFlow(RecipeDetailUiState())
val detailState: StateFlow<RecipeDetailUiState> = _detailState.asStateFlow()
init {
viewModelScope.launch {
recipeRepository.getLocalRecipes().collect { recipes ->
_listState.update { it.copy(recipes = recipes, isLoading = false) }
}
}
fetchRecipes()
}
fun fetchRecipes(filter: String = _listState.value.currentFilter, search: String? = null, page: Int = 1) {
viewModelScope.launch {
_listState.update { it.copy(isLoading = true, error = null, currentFilter = filter, searchQuery = search ?: "") }
when (val result = recipeRepository.fetchRecipes(filter, page, search)) {
is Result.Success -> _listState.update {
it.copy(
isLoading = false,
currentPage = result.data.pagination.page,
totalPages = result.data.pagination.totalPages
)
}
is Result.Error -> _listState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
is Result.NetworkError -> _listState.update { it.copy(isLoading = false) }
}
}
}
fun loadRecipeDetail(recipeId: String, scale: Int = 1) {
viewModelScope.launch {
_detailState.value = RecipeDetailUiState(isLoading = true, scaleFactor = scale)
when (val result = recipeRepository.getRecipeById(recipeId, scale)) {
is Result.Success -> _detailState.value = RecipeDetailUiState(recipe = result.data.recipe, scaleFactor = scale)
is Result.Error -> _detailState.value = RecipeDetailUiState(error = result.toUserMessage())
is Result.NetworkError -> _detailState.value = RecipeDetailUiState(error = "No internet connection.")
}
}
}
fun setScale(recipeId: String, scale: Int) {
if (scale in 1..3) loadRecipeDetail(recipeId, scale)
}
}

View File

@@ -1,168 +0,0 @@
package com.pantree.app.ui.screens.recipe
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pantree.app.data.local.entity.RecipeEntity
import com.pantree.app.ui.components.EmptyState
import com.pantree.app.ui.components.ErrorState
import com.pantree.app.ui.components.LoadingState
import com.pantree.app.ui.theme.SuccessGreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecipesScreen(
onRecipeClick: (String) -> Unit,
onNavigateBack: () -> Unit,
viewModel: RecipeViewModel = hiltViewModel()
) {
val uiState by viewModel.listState.collectAsStateWithLifecycle()
var searchText by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
Scaffold(
topBar = {
if (showSearch) {
TopAppBar(
title = {
OutlinedTextField(
value = searchText,
onValueChange = {
searchText = it
viewModel.fetchRecipes(search = it.ifBlank { null })
},
placeholder = { Text("Search recipes...") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent)
)
},
navigationIcon = {
IconButton(onClick = { showSearch = false; searchText = ""; viewModel.fetchRecipes() }) {
Icon(Icons.Default.ArrowBack, "Close search", tint = MaterialTheme.colorScheme.onPrimary)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primary)
)
} else {
TopAppBar(
title = { Text("Recipes") },
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary) } },
actions = {
IconButton(onClick = { showSearch = true }) {
Icon(Icons.Default.Search, "Search", tint = MaterialTheme.colorScheme.onPrimary)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
// Filter chips
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf("all" to "All", "can_make" to "Can make", "can_partially_make" to "Partial").forEach { (value, label) ->
FilterChip(
selected = uiState.currentFilter == value,
onClick = { viewModel.fetchRecipes(filter = value) },
label = { Text(label) }
)
}
}
when {
uiState.isLoading && uiState.recipes.isEmpty() -> LoadingState(message = "Finding recipes...")
uiState.error != null && uiState.recipes.isEmpty() -> ErrorState(
message = uiState.error!!,
onRetry = { viewModel.fetchRecipes() }
)
uiState.recipes.isEmpty() -> EmptyState(
emoji = "\uD83D\uDCDA",
title = when (uiState.currentFilter) {
"can_make" -> "Nothing to cook yet"
"can_partially_make" -> "No partial matches"
else -> "No recipes found"
},
subtitle = when (uiState.currentFilter) {
"can_make" -> "Add more ingredients to your pantry to unlock recipes."
"can_partially_make" -> "Try adding a few more pantry items."
else -> "Try a different search term."
}
)
else -> {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(uiState.recipes, key = { it.id }) { recipe ->
RecipeCard(recipe = recipe, onClick = { onRecipeClick(recipe.id) })
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}
}
}
}
@Composable
fun RecipeCard(recipe: RecipeEntity, onClick: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(recipe.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
if (recipe.canMake) {
Surface(
color = SuccessGreen.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small
) {
Text(
"\u2705 Can make",
style = MaterialTheme.typography.labelLarge,
color = SuccessGreen,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"${recipe.servings} servings",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
"${recipe.availableIngredientCount}/${recipe.ingredientCount} ingredients",
style = MaterialTheme.typography.bodySmall,
color = if (recipe.canMake) SuccessGreen else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
if (!recipe.canMake && recipe.ingredientCount > 0) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { (recipe.availabilityPercentage / 100f).toFloat() },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View File

@@ -1,132 +0,0 @@
package com.pantree.app.ui.screens.settings
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onSignOut: () -> Unit,
onNavigateBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showDeleteConfirm by remember { mutableStateOf(false) }
LaunchedEffect(uiState.signedOut) { if (uiState.signedOut) onSignOut() }
LaunchedEffect(uiState.accountDeleted) { if (uiState.accountDeleted) onSignOut() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (uiState.error != null) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth()
) {
Text(
uiState.error!!,
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(Modifier.height(8.dp))
Text("Account", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(4.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column {
ListItem(
headlineContent = { Text("Sign out") },
supportingContent = { Text("You can sign back in anytime.") },
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { viewModel.signOut() },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text("Sign out")
}
}
}
Spacer(Modifier.height(16.dp))
Text("Danger zone", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(4.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f))
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Delete account", style = MaterialTheme.typography.titleMedium)
Text(
"Your account will be scheduled for deletion. You have 15 days to change your mind — just sign back in.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
OutlinedButton(
onClick = { showDeleteConfirm = true },
enabled = !uiState.isLoading,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp))
} else {
Text("Delete my account")
}
}
}
}
}
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("Delete account?") },
text = {
Text(
"This will schedule your account for permanent deletion in 15 days. " +
"All your pantry items, recipes, and shopping lists will be removed. " +
"Sign back in within 15 days to cancel."
)
},
confirmButton = {
TextButton(onClick = { viewModel.deleteAccount(); showDeleteConfirm = false }) {
Text("Yes, delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Keep my account") }
}
)
}
}

View File

@@ -1,51 +0,0 @@
package com.pantree.app.ui.screens.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pantree.app.data.repository.AuthRepository
import com.pantree.app.util.Result
import com.pantree.app.util.toUserMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsUiState(
val isLoading: Boolean = false,
val error: String? = null,
val signedOut: Boolean = false,
val accountDeleted: Boolean = false
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
fun signOut() {
authRepository.logout()
_uiState.update { it.copy(signedOut = true) }
}
fun deleteAccount() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = authRepository.deleteAccount()) {
is Result.Success -> {
authRepository.logout()
_uiState.update { it.copy(isLoading = false, accountDeleted = true) }
}
is Result.Error -> _uiState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
is Result.NetworkError -> _uiState.update { it.copy(isLoading = false, error = "No internet connection.") }
}
}
}
fun clearError() = _uiState.update { it.copy(error = null) }
}

View File

@@ -1,305 +0,0 @@
package com.pantree.app.ui.screens.shopping
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pantree.app.data.local.entity.ShoppingListItemEntity
import com.pantree.app.ui.components.EmptyState
import com.pantree.app.ui.components.ErrorState
import com.pantree.app.ui.components.LoadingState
val ALLOWED_UNITS = listOf(
"cups", "tbsp", "tsp", "oz", "fl_oz",
"g", "kg", "ml", "l",
"pieces", "slices", "cloves", "pinch",
"whole", "can", "package", "bunch"
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShoppingListDetailScreen(
listId: String,
onNavigateBack: () -> Unit,
onNavigateToRecipes: () -> Unit,
viewModel: ShoppingListViewModel = hiltViewModel()
) {
val uiState by viewModel.detailState.collectAsStateWithLifecycle()
var showAddItemDialog by remember { mutableStateOf(false) }
var deletingItemId by remember { mutableStateOf<String?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(listId) { viewModel.loadListDetail(listId) }
LaunchedEffect(uiState.actionSuccess) {
uiState.actionSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() }
}
LaunchedEffect(uiState.actionError) {
uiState.actionError?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() }
}
LaunchedEffect(uiState.addRecipesSuccess) {
uiState.addRecipesSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() }
}
val uncheckedItems = uiState.items.filter { !it.checkedOff }
val checkedItems = uiState.items.filter { it.checkedOff }
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.listName.ifBlank { "Shopping List" }) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary)
}
},
actions = {
IconButton(onClick = onNavigateToRecipes) {
Icon(Icons.Default.MenuBook, "Add from recipes", tint = MaterialTheme.colorScheme.onPrimary)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showAddItemDialog = true }) {
Icon(Icons.Default.Add, "Add item")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
when {
uiState.isLoading && uiState.items.isEmpty() -> LoadingState(message = "Loading list...")
uiState.error != null && uiState.items.isEmpty() -> ErrorState(
message = uiState.error!!,
onRetry = { viewModel.loadListDetail(listId) }
)
uiState.items.isEmpty() -> EmptyState(
emoji = "\uD83D\uDCCB",
title = "This list is empty",
subtitle = "Add items manually or pull in ingredients from a recipe.",
actionLabel = "Add an item",
onAction = { showAddItemDialog = true }
)
else -> {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Progress summary
item {
if (uiState.items.isNotEmpty()) {
val progress = checkedItems.size.toFloat() / uiState.items.size
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"${checkedItems.size} of ${uiState.items.size} checked",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
}
}
}
// Unchecked items
items(uncheckedItems, key = { it.id }) { item ->
ShoppingListItemRow(
item = item,
onToggle = { viewModel.toggleCheckOff(listId, item) },
onDelete = { deletingItemId = item.id }
)
}
// Checked items section
if (checkedItems.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
Text(
"In cart (${checkedItems.size})",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
Spacer(Modifier.height(4.dp))
}
items(checkedItems, key = { it.id }) { item ->
ShoppingListItemRow(
item = item,
onToggle = { viewModel.toggleCheckOff(listId, item) },
onDelete = { deletingItemId = item.id }
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
}
if (showAddItemDialog) {
AddShoppingItemDialog(
onDismiss = { showAddItemDialog = false },
onConfirm = { name, qty, unit ->
viewModel.addItem(listId, name, qty, unit)
showAddItemDialog = false
}
)
}
deletingItemId?.let { itemId ->
AlertDialog(
onDismissRequest = { deletingItemId = null },
title = { Text("Remove item?") },
text = { Text("Remove this item from the list?") },
confirmButton = {
TextButton(onClick = { viewModel.deleteItem(listId, itemId); deletingItemId = null }) {
Text("Remove", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { deletingItemId = null }) { Text("Cancel") } }
)
}
}
@Composable
fun ShoppingListItemRow(
item: ShoppingListItemEntity,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (item.checkedOff)
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = item.checkedOff,
onCheckedChange = { onToggle() }
)
Column(modifier = Modifier.weight(1f)) {
Text(
item.itemName,
style = MaterialTheme.typography.bodyLarge.copy(
textDecoration = if (item.checkedOff) TextDecoration.LineThrough else null
),
color = if (item.checkedOff)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.onSurface
)
Text(
"${formatQty(item.quantity)} ${item.unit}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
}
}
}
}
fun formatQty(qty: Double): String =
if (qty == qty.toLong().toDouble()) qty.toLong().toString()
else String.format("%.2f", qty).trimEnd('0').trimEnd('.')
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddShoppingItemDialog(onDismiss: () -> Unit, onConfirm: (String, Double, String) -> Unit) {
var name by remember { mutableStateOf("") }
var quantityText by remember { mutableStateOf("1") }
var selectedUnit by remember { mutableStateOf("pieces") }
var unitExpanded by remember { mutableStateOf(false) }
val quantity = quantityText.toDoubleOrNull()
val isValid = name.isNotBlank() && quantity != null && quantity > 0
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add item") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Item name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = quantityText,
onValueChange = { quantityText = it },
label = { Text("Qty") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
isError = quantityText.isNotEmpty() && quantity == null,
modifier = Modifier.weight(1f)
)
ExposedDropdownMenuBox(
expanded = unitExpanded,
onExpandedChange = { unitExpanded = it },
modifier = Modifier.weight(1.5f)
) {
OutlinedTextField(
value = selectedUnit,
onValueChange = {},
readOnly = true,
label = { Text("Unit") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) },
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = unitExpanded,
onDismissRequest = { unitExpanded = false }
) {
ALLOWED_UNITS.forEach { unit ->
DropdownMenuItem(
text = { Text(unit) },
onClick = { selectedUnit = unit; unitExpanded = false }
)
}
}
}
}
}
},
confirmButton = {
TextButton(
onClick = { if (isValid) onConfirm(name, quantity!!, selectedUnit) },
enabled = isValid
) { Text("Add") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}

View File

@@ -1,148 +0,0 @@
package com.pantree.app.ui.screens.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.repository.ShoppingListRepository
import com.pantree.app.util.Result
import com.pantree.app.util.toUserMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ShoppingListsUiState(
val isLoading: Boolean = false,
val lists: List<ShoppingListEntity> = emptyList(),
val error: String? = null,
val actionError: String? = null,
val actionSuccess: String? = null
)
data class ShoppingListDetailUiState(
val isLoading: Boolean = false,
val listName: String = "",
val items: List<ShoppingListItemEntity> = emptyList(),
val error: String? = null,
val actionError: String? = null,
val actionSuccess: String? = null,
val addRecipesSuccess: String? = null
)
@HiltViewModel
class ShoppingListViewModel @Inject constructor(
private val shoppingListRepository: ShoppingListRepository
) : ViewModel() {
private val _listsState = MutableStateFlow(ShoppingListsUiState(isLoading = true))
val listsState: StateFlow<ShoppingListsUiState> = _listsState.asStateFlow()
private val _detailState = MutableStateFlow(ShoppingListDetailUiState())
val detailState: StateFlow<ShoppingListDetailUiState> = _detailState.asStateFlow()
init {
viewModelScope.launch {
shoppingListRepository.getLocalLists().collect { lists ->
_listsState.update { it.copy(lists = lists, isLoading = false) }
}
}
fetchLists()
}
fun fetchLists() {
viewModelScope.launch {
_listsState.update { it.copy(isLoading = true, error = null) }
when (val result = shoppingListRepository.fetchLists()) {
is Result.Success -> _listsState.update { it.copy(isLoading = false) }
is Result.Error -> _listsState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
is Result.NetworkError -> _listsState.update { it.copy(isLoading = false) }
}
}
}
fun createList(name: String) {
viewModelScope.launch {
when (val result = shoppingListRepository.createList(name.trim())) {
is Result.Success -> _listsState.update { it.copy(actionSuccess = "\"${result.data.shoppingList.listName}\" created.") }
is Result.Error -> _listsState.update { it.copy(actionError = result.toUserMessage()) }
is Result.NetworkError -> _listsState.update { it.copy(actionError = "No internet connection.") }
}
}
}
fun deleteList(listId: String) {
viewModelScope.launch {
when (val result = shoppingListRepository.deleteList(listId)) {
is Result.Success -> _listsState.update { it.copy(actionSuccess = "List deleted.") }
is Result.Error -> _listsState.update { it.copy(actionError = result.toUserMessage()) }
is Result.NetworkError -> _listsState.update { it.copy(actionError = "No internet connection.") }
}
}
}
fun loadListDetail(listId: String) {
viewModelScope.launch {
_detailState.update { it.copy(isLoading = true, error = null) }
// Observe local items immediately
launch {
shoppingListRepository.getLocalItems(listId).collect { items ->
_detailState.update { it.copy(items = items, isLoading = false) }
}
}
when (val result = shoppingListRepository.fetchListById(listId)) {
is Result.Success -> _detailState.update {
it.copy(isLoading = false, listName = result.data.shoppingList.listName)
}
is Result.Error -> _detailState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
is Result.NetworkError -> _detailState.update { it.copy(isLoading = false) }
}
}
}
fun addItem(listId: String, name: String, quantity: Double, unit: String) {
viewModelScope.launch {
when (val result = shoppingListRepository.addItem(listId, name.trim(), quantity, unit)) {
is Result.Success -> {
val msg = if (result.data.merged == true)
"Merged with existing ${result.data.item.itemName}."
else "${result.data.item.itemName} added."
_detailState.update { it.copy(actionSuccess = msg, actionError = null) }
}
is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) }
is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") }
}
}
}
fun addRecipesToList(listId: String, recipeIds: List<String>, scaleFactor: Int) {
viewModelScope.launch {
when (val result = shoppingListRepository.addRecipesToList(listId, recipeIds, scaleFactor)) {
is Result.Success -> _detailState.update {
it.copy(addRecipesSuccess = "Added ${result.data.recipesAdded} recipe(s). ${result.data.itemsMerged} items merged, ${result.data.itemsCreated} new.")
}
is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) }
is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") }
}
}
}
fun toggleCheckOff(listId: String, item: ShoppingListItemEntity) {
viewModelScope.launch {
shoppingListRepository.updateItem(listId, item.id, checkedOff = !item.checkedOff)
}
}
fun deleteItem(listId: String, itemId: String) {
viewModelScope.launch {
when (val result = shoppingListRepository.deleteItem(listId, itemId)) {
is Result.Success -> _detailState.update { it.copy(actionSuccess = "Item removed.") }
is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) }
is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") }
}
}
}
fun clearListsMessages() = _listsState.update { it.copy(actionError = null, actionSuccess = null) }
fun clearDetailMessages() = _detailState.update { it.copy(actionError = null, actionSuccess = null, addRecipesSuccess = null) }
}

View File

@@ -1,175 +0,0 @@
package com.pantree.app.ui.screens.shopping
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pantree.app.data.local.entity.ShoppingListEntity
import com.pantree.app.ui.components.EmptyState
import com.pantree.app.ui.components.ErrorState
import com.pantree.app.ui.components.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShoppingListsScreen(
onListClick: (String) -> Unit,
onNavigateBack: () -> Unit,
viewModel: ShoppingListViewModel = hiltViewModel()
) {
val uiState by viewModel.listsState.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) }
var deletingList by remember { mutableStateOf<ShoppingListEntity?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.actionSuccess) {
uiState.actionSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearListsMessages() }
}
LaunchedEffect(uiState.actionError) {
uiState.actionError?.let { snackbarHostState.showSnackbar(it); viewModel.clearListsMessages() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Shopping Lists") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreateDialog = true }) {
Icon(Icons.Default.Add, "Create list")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
when {
uiState.isLoading && uiState.lists.isEmpty() -> LoadingState(message = "Loading your lists...")
uiState.error != null && uiState.lists.isEmpty() -> ErrorState(
message = uiState.error!!,
onRetry = { viewModel.fetchLists() }
)
uiState.lists.isEmpty() -> EmptyState(
emoji = "\uD83D\uDED2",
title = "No shopping lists yet",
subtitle = "Create a list to start planning your next grocery run.",
actionLabel = "Create a list",
onAction = { showCreateDialog = true }
)
else -> {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(uiState.lists, key = { it.id }) { list ->
ShoppingListCard(
list = list,
onClick = { onListClick(list.id) },
onDelete = { deletingList = list }
)
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
}
if (showCreateDialog) {
CreateListDialog(
onDismiss = { showCreateDialog = false },
onConfirm = { name -> viewModel.createList(name); showCreateDialog = false }
)
}
deletingList?.let { list ->
AlertDialog(
onDismissRequest = { deletingList = null },
title = { Text("Delete list?") },
text = { Text("\"${list.listName}\" and all its items will be permanently deleted.") },
confirmButton = {
TextButton(onClick = { viewModel.deleteList(list.id); deletingList = null }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { deletingList = null }) { Text("Cancel") } }
)
}
}
@Composable
fun ShoppingListCard(
list: ShoppingListEntity,
onClick: () -> Unit,
onDelete: () -> Unit
) {
Card(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(list.listName, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"${list.itemCount} item${if (list.itemCount != 1) "s" else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
if (list.checkedCount > 0) {
Text(
"${list.checkedCount} checked",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f))
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
fun CreateListDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New shopping list") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("List name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(onClick = { if (name.isNotBlank()) onConfirm(name) }, enabled = name.isNotBlank()) {
Text("Create")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}

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

@@ -2,23 +2,38 @@ package com.pantree.app.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// Brand palette // ─── Pantree Brand Palette ────────────────────────────────────────────────────
val PantreeGreen = Color(0xFF2D6A4F) // Warm, earthy, approachable. Not a sterile medical app. Not a finance dashboard.
val PantreeGreenLight = Color(0xFF52B788) // Something that feels like a kitchen.
val PantreeGreenDark = Color(0xFF1B4332)
val PantreeCream = Color(0xFFF8F4EF)
val PantreeOrange = Color(0xFFE76F51)
val PantreeOrangeLight = Color(0xFFF4A261)
// Semantic val PantreeGreen = Color(0xFF3D7A5A)
val SuccessGreen = Color(0xFF40916C) val PantreeGreenLight = Color(0xFF5A9E78)
val ErrorRed = Color(0xFFD62828) val PantreeGreenDark = Color(0xFF2A5740)
val WarningAmber = Color(0xFFF4A261)
val NeutralGray = Color(0xFF6B7280)
val SurfaceWhite = Color(0xFFFFFFFF)
val BackgroundCream = Color(0xFFF8F4EF)
// Dark theme val PantreeOrange = Color(0xFFE07B39)
val PantreeGreenDarkTheme = Color(0xFF52B788) val PantreeOrangeLight = Color(0xFFEA9B62)
val SurfaceDark = Color(0xFF1C1C1E) val PantreeOrangeDark = Color(0xFFB85E22)
val BackgroundDark = Color(0xFF121212)
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

@@ -2,9 +2,7 @@ package com.pantree.app.ui.theme
import android.app.Activity import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
@@ -13,34 +11,42 @@ import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = PantreeGreen, primary = PantreeGreen,
onPrimary = SurfaceWhite, onPrimary = White,
primaryContainer = PantreeGreenLight, primaryContainer = PantreeGreenLight,
onPrimaryContainer = PantreeGreenDark, onPrimaryContainer = PantreeGreenDark,
secondary = PantreeOrange, secondary = PantreeOrange,
onSecondary = SurfaceWhite, onSecondary = White,
secondaryContainer = PantreeOrangeLight, secondaryContainer = PantreeOrangeLight,
background = BackgroundCream, onSecondaryContainer = PantreeOrangeDark,
onBackground = PantreeGreenDark, tertiary = PantreeBrown,
surface = SurfaceWhite, onTertiary = White,
onSurface = PantreeGreenDark, background = PantreeCream,
error = ErrorRed, onBackground = PantreeGray900,
onError = SurfaceWhite, surface = White,
outline = NeutralGray onSurface = PantreeGray900,
surfaceVariant = PantreeCreamDark,
onSurfaceVariant = PantreeGray600,
error = PantreeRed,
onError = White,
outline = PantreeGray400
) )
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = PantreeGreenDarkTheme, primary = PantreeGreenLight,
onPrimary = PantreeGreenDark, onPrimary = PantreeGreenDark,
primaryContainer = PantreeGreenDark, primaryContainer = PantreeGreenDark,
onPrimaryContainer = PantreeGreenLight, onPrimaryContainer = PantreeGreenLight,
secondary = PantreeOrangeLight, secondary = PantreeOrangeLight,
onSecondary = SurfaceDark, onSecondary = PantreeOrangeDark,
background = BackgroundDark, background = PantreeGray900,
onBackground = PantreeCream, onBackground = PantreeCream,
surface = SurfaceDark, surface = PantreeGray800,
onSurface = PantreeCream, onSurface = PantreeCream,
error = ErrorRed, surfaceVariant = PantreeGray800,
onError = SurfaceWhite onSurfaceVariant = PantreeGray400,
error = PantreeRedLight,
onError = PantreeGray900,
outline = PantreeGray600
) )
@Composable @Composable
@@ -49,6 +55,7 @@ fun PantreeTheme(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
@@ -57,6 +64,7 @@ fun PantreeTheme(
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
} }
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = PantreeTypography, typography = PantreeTypography,

View File

@@ -2,53 +2,92 @@ package com.pantree.app.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
val PantreeTypography = Typography( val PantreeTypography = Typography(
headlineLarge = TextStyle( displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 40.sp lineHeight = 40.sp,
letterSpacing = (-0.5).sp
), ),
headlineMedium = TextStyle( displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 24.sp, fontSize = 24.sp,
lineHeight = 32.sp lineHeight = 32.sp
), ),
headlineSmall = TextStyle( headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 20.sp, fontSize = 20.sp,
lineHeight = 28.sp lineHeight = 28.sp
), ),
titleLarge = TextStyle( headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 18.sp, fontSize = 18.sp,
lineHeight = 24.sp lineHeight = 24.sp
), ),
titleMedium = TextStyle( titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 22.sp lineHeight = 24.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
), ),
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp lineHeight = 24.sp
), ),
bodyMedium = TextStyle( bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp lineHeight = 20.sp
), ),
bodySmall = TextStyle( bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp lineHeight = 16.sp
), ),
labelLarge = TextStyle( labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.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

@@ -1,58 +0,0 @@
package com.pantree.app.util
import com.google.gson.Gson
import com.pantree.app.data.model.ApiError
import retrofit2.Response
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val code: String, val message: String, val httpStatus: Int = 0) : Result<Nothing>()
object NetworkError : Result<Nothing>()
}
suspend fun <T> safeApiCall(gson: Gson, call: suspend () -> Response<T>): Result<T> {
return try {
val response = call()
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Result.Success(body)
} else if (response.code() == 204) {
@Suppress("UNCHECKED_CAST")
Result.Success(Unit as T)
} else {
Result.Error("EMPTY_BODY", "Empty response body", response.code())
}
} else {
val errorBody = response.errorBody()?.string()
val apiError = try {
gson.fromJson(errorBody, ApiError::class.java)
} catch (e: Exception) {
null
}
Result.Error(
code = apiError?.code ?: "UNKNOWN_ERROR",
message = apiError?.error ?: "Something went wrong. Please try again.",
httpStatus = response.code()
)
}
} catch (e: java.io.IOException) {
Result.NetworkError
} catch (e: Exception) {
Result.Error("UNKNOWN_ERROR", e.message ?: "An unexpected error occurred.")
}
}
fun Result.Error.toUserMessage(): String = when (code) {
"VALIDATION_ERROR" -> message
"UNAUTHORIZED" -> "Your session has expired. Please sign in again."
"FORBIDDEN" -> message
"NOT_FOUND" -> "That item couldn't be found."
"CONFLICT" -> message
"DUPLICATE_ITEM" -> message
"ACCOUNT_PENDING_DELETION" -> message
"GONE" -> "This account no longer exists."
"INVALID_TOKEN" -> "That link has expired. Please request a new one."
"INVALID_GOOGLE_TOKEN" -> "Google sign-in failed. Please try again."
else -> "Something went wrong. Please try again."
}

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Pantree</string>
</resources>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Pantree" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

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,25 +0,0 @@
package com.pantree.app.ui.screens.recipe
import org.junit.Assert.*
import org.junit.Test
class RecipeDetailScreenTest {
@Test
fun `formatQuantity whole number strips decimal`() {
assertEquals("2", formatQuantity(2.0))
assertEquals("10", formatQuantity(10.0))
}
@Test
fun `formatQuantity decimal trims trailing zeros`() {
assertEquals("2.5", formatQuantity(2.5))
assertEquals("0.25", formatQuantity(0.25))
}
@Test
fun `formatQuantity handles repeating decimal`() {
val result = formatQuantity(0.6666666666)
assertTrue(result.startsWith("0.66"))
}
}

View File

@@ -1,31 +0,0 @@
package com.pantree.app.util
import org.junit.Assert.*
import org.junit.Test
class ResultTest {
@Test
fun `toUserMessage returns human message for UNAUTHORIZED`() {
val error = Result.Error(code = "UNAUTHORIZED", message = "raw", httpStatus = 401)
assertEquals("Your session has expired. Please sign in again.", error.toUserMessage())
}
@Test
fun `toUserMessage returns human message for NOT_FOUND`() {
val error = Result.Error(code = "NOT_FOUND", message = "raw", httpStatus = 404)
assertEquals("That item couldn't be found.", error.toUserMessage())
}
@Test
fun `toUserMessage passes through VALIDATION_ERROR message`() {
val error = Result.Error(code = "VALIDATION_ERROR", message = "Email is required", httpStatus = 400)
assertEquals("Email is required", error.toUserMessage())
}
@Test
fun `toUserMessage returns generic for unknown code`() {
val error = Result.Error(code = "SOME_WEIRD_CODE", message = "raw", httpStatus = 500)
assertEquals("Something went wrong. Please try again.", error.toUserMessage())
}
}

View File

@@ -1,8 +0,0 @@
// Top-level build file
plugins {
id 'com.android.application' version '8.3.0' apply false
id 'com.android.library' version '8.3.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.23' apply false
id 'com.google.dagger.hilt.android' version '2.51.1' apply false
id 'com.google.devtools.ksp' version '1.9.23-1.0.20' apply false
}

View File

@@ -1,16 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Pantree"
include ':app'

View File

@@ -1,24 +0,0 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.test.ts',
'!src/db/knexfile.ts',
'!src/db/migrations/**',
'!src/db/seeds/**',
'!src/server.ts'
],
coverageThreshold: {
global: {
branches: 70,
functions: 80,
lines: 80,
statements: 80
}
},
setupFiles: ['<rootDir>/src/test/setup.ts']
};

View File

@@ -1,49 +0,0 @@
{
"name": "pantree-backend",
"version": "1.0.0",
"description": "Pantree API Server",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --runInBand --forceExit --coverage",
"migrate": "knex migrate:latest --knexfile src/db/knexfile.ts",
"migrate:rollback": "knex migrate:rollback --knexfile src/db/knexfile.ts",
"seed": "knex seed:run --knexfile src/db/knexfile.ts"
},
"dependencies": {
"@sendgrid/mail": "^8.1.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"google-auth-library": "^9.10.0",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"node-cron": "^3.0.3",
"pg": "^8.11.5",
"pino": "^9.1.0",
"pino-http": "^10.1.0",
"uuid": "^9.0.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.12.12",
"@types/node-cron": "^3.0.11",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}

View File

@@ -1,42 +0,0 @@
import express from 'express';
import cors from 'cors';
import { httpLogger } from './utils/logger';
import { errorHandler } from './middleware/errorHandler';
import authRoutes from './routes/auth';
import pantryRoutes from './routes/pantry';
import recipeRoutes from './routes/recipes';
import shoppingListRoutes from './routes/shoppingLists';
import syncRoutes from './routes/sync';
export function createApp() {
const app = express();
app.use(cors());
app.use(express.json());
app.use(httpLogger);
// Health check
app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Routes
app.use('/v1/auth', authRoutes);
app.use('/v1/pantry', pantryRoutes);
app.use('/v1/recipes', recipeRoutes);
app.use('/v1/shopping-lists', shoppingListRoutes);
app.use('/v1/sync', syncRoutes);
// 404 handler
app.use((_req, res) => {
res.status(404).json({
error: 'Route not found.',
code: 'NOT_FOUND',
timestamp: new Date().toISOString(),
});
});
app.use(errorHandler);
return app;
}

View File

@@ -1,18 +0,0 @@
export const ALLOWED_UNITS = [
'cups', 'tbsp', 'tsp', 'oz', 'fl_oz',
'g', 'kg', 'ml', 'l',
'pieces', 'slices', 'cloves', 'pinch',
'whole', 'can', 'package', 'bunch'
] as const;
export type AllowedUnit = typeof ALLOWED_UNITS[number];
export const BCRYPT_ROUNDS = 12;
export const JWT_EXPIRES_IN = '24h';
export const PASSWORD_RESET_EXPIRES_HOURS = 1;
export const ACCOUNT_DELETION_DAYS = 15;
export const TOMBSTONE_RETENTION_DAYS = 30;
export const MAX_RECIPE_SCALE = 3;
export const MIN_RECIPE_SCALE = 1;
export const DEFAULT_PAGE_LIMIT = 20;
export const MAX_PAGE_LIMIT = 50;

View File

@@ -1,26 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const config = {
nodeEnv: process.env.NODE_ENV ?? 'development',
port: parseInt(process.env.PORT ?? '3000', 10),
databaseUrl: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test',
jwtSecret: process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h',
googleClientId: process.env.GOOGLE_CLIENT_ID ?? '',
sendgridApiKey: process.env.SENDGRID_API_KEY ?? '',
sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app',
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password',
isTest: process.env.NODE_ENV === 'test',
isDev: process.env.NODE_ENV === 'development',
isProd: process.env.NODE_ENV === 'production',
};

View File

@@ -1,6 +0,0 @@
import knex from 'knex';
import knexConfig from './knexfile';
const db = knex(knexConfig);
export default db;

View File

@@ -1,22 +0,0 @@
import type { Knex } from 'knex';
import { config } from '../config/env';
const knexConfig: Knex.Config = {
client: 'pg',
connection: config.databaseUrl,
migrations: {
directory: './migrations',
extension: 'ts',
},
seeds: {
directory: './seeds',
extension: 'ts',
},
pool: {
min: 2,
max: 10,
},
};
export default knexConfig;
module.exports = knexConfig;

View File

@@ -1,27 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "pgcrypto"');
await knex.schema.createTable('users', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('email', 255).notNullable();
table.string('password_hash', 255).nullable();
table.string('name', 255).notNullable();
table.text('profile_picture_url').nullable();
table.string('google_id', 255).nullable();
table.boolean('email_verified').defaultTo(false);
table.timestamp('deleted_at', { useTz: true }).nullable();
table.timestamp('deletion_scheduled_at', { useTz: true }).nullable();
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
});
await knex.raw(`CREATE UNIQUE INDEX idx_users_email ON users (LOWER(email))`);
await knex.raw(`CREATE UNIQUE INDEX idx_users_google_id ON users (google_id) WHERE google_id IS NOT NULL`);
await knex.raw(`CREATE INDEX idx_users_deletion_scheduled ON users (deletion_scheduled_at) WHERE deletion_scheduled_at IS NOT NULL`);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('users');
}

View File

@@ -1,18 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('password_reset_tokens', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
table.string('token_hash', 255).notNullable();
table.timestamp('expires_at', { useTz: true }).notNullable();
table.timestamp('used_at', { useTz: true }).nullable();
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
});
await knex.raw(`CREATE INDEX idx_prt_user_id ON password_reset_tokens (user_id)`);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('password_reset_tokens');
}

View File

@@ -1,22 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('pantry_items', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
table.string('item_name', 255).notNullable();
table.string('item_name_lower', 255).notNullable();
table.integer('quantity').notNullable().checkPositive();
table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now());
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
});
await knex.raw(`CREATE UNIQUE INDEX idx_pantry_user_item ON pantry_items (user_id, item_name_lower)`);
await knex.raw(`CREATE INDEX idx_pantry_user_id ON pantry_items (user_id)`);
await knex.raw(`CREATE INDEX idx_pantry_last_modified ON pantry_items (user_id, last_modified)`);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('pantry_items');
}

View File

@@ -1,30 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('recipes', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('name', 255).notNullable();
table.text('instructions').notNullable();
table.integer('servings').notNullable().checkPositive();
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
});
await knex.schema.createTable('recipe_ingredients', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('recipe_id').notNullable().references('id').inTable('recipes').onDelete('CASCADE');
table.string('item_name', 255).notNullable();
table.string('item_name_lower', 255).notNullable();
table.decimal('quantity', 10, 4).notNullable().checkPositive();
table.string('unit', 50).notNullable();
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
});
await knex.raw(`CREATE INDEX idx_recipe_ingredients_recipe ON recipe_ingredients (recipe_id)`);
await knex.raw(`CREATE INDEX idx_recipe_ingredients_name ON recipe_ingredients (item_name_lower)`);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('recipe_ingredients');
await knex.schema.dropTableIfExists('recipes');
}

View File

@@ -1,36 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('shopping_lists', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
table.string('list_name', 255).notNullable();
table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now());
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
});
await knex.schema.createTable('shopping_list_items', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('shopping_list_id').notNullable().references('id').inTable('shopping_lists').onDelete('CASCADE');
table.string('item_name', 255).notNullable();
table.string('item_name_lower', 255).notNullable();
table.decimal('quantity', 10, 4).notNullable().checkPositive();
table.string('unit', 50).notNullable();
table.boolean('checked_off').defaultTo(false);
table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now());
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
});
await knex.raw(`CREATE INDEX idx_shopping_lists_user ON shopping_lists (user_id)`);
await knex.raw(`CREATE INDEX idx_shopping_lists_last_modified ON shopping_lists (user_id, last_modified)`);
await knex.raw(`CREATE INDEX idx_list_items_list ON shopping_list_items (shopping_list_id)`);
await knex.raw(`CREATE INDEX idx_list_items_name_unit ON shopping_list_items (shopping_list_id, item_name_lower, unit)`);
await knex.raw(`CREATE INDEX idx_list_items_last_modified ON shopping_list_items (shopping_list_id, last_modified)`);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('shopping_list_items');
await knex.schema.dropTableIfExists('shopping_lists');
}

View File

@@ -1,17 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('deleted_records', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('user_id').notNullable();
table.string('table_name', 50).notNullable();
table.uuid('record_id').notNullable();
table.timestamp('deleted_at', { useTz: true }).defaultTo(knex.fn.now());
});
await knex.raw(`CREATE INDEX idx_deleted_records_user_time ON deleted_records (user_id, deleted_at)`);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('deleted_records');
}

View File

@@ -1,164 +0,0 @@
import type { Knex } from 'knex';
const recipes = [
{
name: 'Chocolate Chip Cookies',
servings: 24,
instructions: '1. Preheat oven to 375°F.\n2. Cream butter and sugars.\n3. Beat in eggs and vanilla.\n4. Mix in flour, baking soda, and salt.\n5. Stir in chocolate chips.\n6. Drop by spoonfuls onto baking sheet.\n7. Bake 9-11 minutes until golden.',
ingredients: [
{ item_name: 'All-Purpose Flour', quantity: 2.25, unit: 'cups' },
{ item_name: 'Butter', quantity: 1, unit: 'cups' },
{ item_name: 'Granulated Sugar', quantity: 0.75, unit: 'cups' },
{ item_name: 'Brown Sugar', quantity: 0.75, unit: 'cups' },
{ item_name: 'Eggs', quantity: 2, unit: 'pieces' },
{ item_name: 'Vanilla Extract', quantity: 1, unit: 'tsp' },
{ item_name: 'Baking Soda', quantity: 1, unit: 'tsp' },
{ item_name: 'Chocolate Chips', quantity: 2, unit: 'cups' },
],
},
{
name: 'Classic Pancakes',
servings: 4,
instructions: '1. Mix dry ingredients.\n2. Whisk wet ingredients separately.\n3. Combine wet and dry.\n4. Cook on greased griddle over medium heat.\n5. Flip when bubbles form.',
ingredients: [
{ item_name: 'All-Purpose Flour', quantity: 1.5, unit: 'cups' },
{ item_name: 'Milk', quantity: 1.25, unit: 'cups' },
{ item_name: 'Eggs', quantity: 1, unit: 'pieces' },
{ item_name: 'Butter', quantity: 3, unit: 'tbsp' },
{ item_name: 'Baking Powder', quantity: 2, unit: 'tsp' },
{ item_name: 'Granulated Sugar', quantity: 1, unit: 'tbsp' },
{ item_name: 'Salt', quantity: 1, unit: 'tsp' },
],
},
{
name: 'Spaghetti Bolognese',
servings: 4,
instructions: '1. Brown ground beef.\n2. Add onion and garlic, cook until soft.\n3. Add tomatoes and simmer 30 minutes.\n4. Cook pasta.\n5. Serve sauce over pasta.',
ingredients: [
{ item_name: 'Spaghetti', quantity: 400, unit: 'g' },
{ item_name: 'Ground Beef', quantity: 500, unit: 'g' },
{ item_name: 'Onion', quantity: 1, unit: 'whole' },
{ item_name: 'Garlic', quantity: 3, unit: 'cloves' },
{ item_name: 'Canned Tomatoes', quantity: 2, unit: 'can' },
{ item_name: 'Olive Oil', quantity: 2, unit: 'tbsp' },
{ item_name: 'Salt', quantity: 1, unit: 'tsp' },
],
},
{
name: 'Caesar Salad',
servings: 2,
instructions: '1. Wash and chop romaine.\n2. Make dressing with garlic, lemon, and parmesan.\n3. Toss lettuce with dressing.\n4. Top with croutons and extra parmesan.',
ingredients: [
{ item_name: 'Romaine Lettuce', quantity: 1, unit: 'whole' },
{ item_name: 'Parmesan Cheese', quantity: 0.5, unit: 'cups' },
{ item_name: 'Garlic', quantity: 2, unit: 'cloves' },
{ item_name: 'Lemon', quantity: 1, unit: 'whole' },
{ item_name: 'Olive Oil', quantity: 3, unit: 'tbsp' },
{ item_name: 'Croutons', quantity: 1, unit: 'cups' },
],
},
{
name: 'Banana Bread',
servings: 8,
instructions: '1. Preheat oven to 350°F.\n2. Mash bananas.\n3. Mix wet ingredients.\n4. Fold in dry ingredients.\n5. Pour into loaf pan.\n6. Bake 60-65 minutes.',
ingredients: [
{ item_name: 'Ripe Bananas', quantity: 3, unit: 'whole' },
{ item_name: 'All-Purpose Flour', quantity: 1.5, unit: 'cups' },
{ item_name: 'Butter', quantity: 0.33, unit: 'cups' },
{ item_name: 'Granulated Sugar', quantity: 0.75, unit: 'cups' },
{ item_name: 'Eggs', quantity: 1, unit: 'pieces' },
{ item_name: 'Baking Soda', quantity: 1, unit: 'tsp' },
{ item_name: 'Salt', quantity: 0.25, unit: 'tsp' },
],
},
{
name: 'Chicken Stir Fry',
servings: 4,
instructions: '1. Slice chicken and vegetables.\n2. Heat oil in wok.\n3. Cook chicken until done.\n4. Add vegetables and stir fry.\n5. Add sauce and toss.\n6. Serve over rice.',
ingredients: [
{ item_name: 'Chicken Breast', quantity: 500, unit: 'g' },
{ item_name: 'Bell Pepper', quantity: 2, unit: 'whole' },
{ item_name: 'Broccoli', quantity: 2, unit: 'cups' },
{ item_name: 'Soy Sauce', quantity: 3, unit: 'tbsp' },
{ item_name: 'Garlic', quantity: 3, unit: 'cloves' },
{ item_name: 'Vegetable Oil', quantity: 2, unit: 'tbsp' },
{ item_name: 'Rice', quantity: 2, unit: 'cups' },
],
},
{
name: 'Guacamole',
servings: 4,
instructions: '1. Halve and pit avocados.\n2. Scoop flesh into bowl.\n3. Mash with fork.\n4. Add lime juice, salt, onion, cilantro.\n5. Mix and adjust seasoning.',
ingredients: [
{ item_name: 'Avocado', quantity: 3, unit: 'whole' },
{ item_name: 'Lime', quantity: 1, unit: 'whole' },
{ item_name: 'Red Onion', quantity: 0.25, unit: 'whole' },
{ item_name: 'Cilantro', quantity: 2, unit: 'tbsp' },
{ item_name: 'Salt', quantity: 0.5, unit: 'tsp' },
],
},
{
name: 'Tomato Soup',
servings: 4,
instructions: '1. Sauté onion and garlic.\n2. Add tomatoes and broth.\n3. Simmer 20 minutes.\n4. Blend until smooth.\n5. Season with salt and pepper.',
ingredients: [
{ item_name: 'Canned Tomatoes', quantity: 2, unit: 'can' },
{ item_name: 'Onion', quantity: 1, unit: 'whole' },
{ item_name: 'Garlic', quantity: 2, unit: 'cloves' },
{ item_name: 'Vegetable Broth', quantity: 2, unit: 'cups' },
{ item_name: 'Olive Oil', quantity: 2, unit: 'tbsp' },
{ item_name: 'Salt', quantity: 1, unit: 'tsp' },
],
},
{
name: 'French Toast',
servings: 2,
instructions: '1. Whisk eggs, milk, and cinnamon.\n2. Dip bread slices.\n3. Cook on buttered pan until golden.\n4. Serve with maple syrup.',
ingredients: [
{ item_name: 'Bread', quantity: 4, unit: 'slices' },
{ item_name: 'Eggs', quantity: 2, unit: 'pieces' },
{ item_name: 'Milk', quantity: 0.25, unit: 'cups' },
{ item_name: 'Butter', quantity: 1, unit: 'tbsp' },
{ item_name: 'Cinnamon', quantity: 0.5, unit: 'tsp' },
],
},
{
name: 'Oatmeal',
servings: 1,
instructions: '1. Bring water or milk to boil.\n2. Add oats.\n3. Cook 5 minutes stirring occasionally.\n4. Top with fruit and honey.',
ingredients: [
{ item_name: 'Rolled Oats', quantity: 0.5, unit: 'cups' },
{ item_name: 'Milk', quantity: 1, unit: 'cups' },
{ item_name: 'Honey', quantity: 1, unit: 'tbsp' },
{ item_name: 'Salt', quantity: 1, unit: 'pinch' },
],
},
];
export async function seed(knex: Knex): Promise<void> {
// Clear existing
await knex('recipe_ingredients').delete();
await knex('recipes').delete();
for (const recipe of recipes) {
const [inserted] = await knex('recipes')
.insert({
name: recipe.name,
servings: recipe.servings,
instructions: recipe.instructions,
})
.returning('id');
const recipeId = inserted.id;
await knex('recipe_ingredients').insert(
recipe.ingredients.map((ing) => ({
recipe_id: recipeId,
item_name: ing.item_name,
item_name_lower: ing.item_name.toLowerCase(),
quantity: ing.quantity,
unit: ing.unit,
}))
);
}
}

View File

@@ -1,34 +0,0 @@
import cron from 'node-cron';
import db from '../db/connection';
import { logger } from '../utils/logger';
import { syncService } from '../services/syncService';
let lastCronRun: Date | null = null;
export function getLastCronRun(): Date | null {
return lastCronRun;
}
export function startCronJobs(): void {
// Daily at 2:00 AM UTC — hard-delete expired accounts
cron.schedule('0 2 * * *', async () => {
logger.info('Running daily account hard-delete job...');
try {
const result = await db('users')
.where('deletion_scheduled_at', '<=', db.fn.now())
.whereNotNull('deletion_scheduled_at')
.delete();
logger.info({ deletedCount: result }, 'Hard-delete job complete.');
// Clean up old tombstones
await syncService.cleanupTombstones();
lastCronRun = new Date();
} catch (err) {
logger.error({ err }, 'Hard-delete cron job failed.');
}
});
logger.info('Cron jobs registered.');
}

View File

@@ -1,52 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config/env';
import { createError } from './errorHandler';
import db from '../db/connection';
export interface AuthenticatedRequest extends Request {
userId?: string;
}
export async function authMiddleware(
req: AuthenticatedRequest,
_res: Response,
next: NextFunction
): Promise<void> {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next(createError('Missing or invalid Authorization header.', 401, 'UNAUTHORIZED'));
}
const token = authHeader.slice(7);
let payload: { userId: string };
try {
payload = jwt.verify(token, config.jwtSecret) as { userId: string };
} catch {
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
}
// Verify user still exists and is not hard-deleted
const user = await db('users')
.where({ id: payload.userId })
.whereNull('deletion_scheduled_at')
.select('id', 'deleted_at', 'deletion_scheduled_at')
.first();
if (!user) {
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
}
// Block access for soft-deleted accounts (except restore endpoint)
if (user.deleted_at && !req.path.includes('/restore-account')) {
return next(createError('Account is pending deletion.', 403, 'FORBIDDEN'));
}
req.userId = payload.userId;
next();
} catch (err) {
next(err);
}
}

View File

@@ -1,47 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { logger } from '../utils/logger';
export interface AppError extends Error {
statusCode?: number;
code?: string;
details?: unknown;
}
export function createError(message: string, statusCode: number, code: string): AppError {
const err: AppError = new Error(message);
err.statusCode = statusCode;
err.code = code;
return err;
}
export function errorHandler(
err: AppError,
_req: Request,
res: Response,
_next: NextFunction
): void {
const timestamp = new Date().toISOString();
if (err instanceof ZodError) {
res.status(400).json({
error: err.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('; '),
code: 'VALIDATION_ERROR',
timestamp,
});
return;
}
const statusCode = err.statusCode ?? 500;
const code = err.code ?? 'INTERNAL_ERROR';
if (statusCode >= 500) {
logger.error({ err }, 'Unhandled server error');
}
res.status(statusCode).json({
error: statusCode >= 500 ? 'An internal error occurred.' : err.message,
code,
timestamp,
});
}

View File

@@ -1,134 +0,0 @@
import { Router, Response, NextFunction } from 'express';
import { authService } from '../services/authService';
import { AuthenticatedRequest, authMiddleware } from '../middleware/auth';
import {
signupSchema,
signinSchema,
googleAuthSchema,
passwordResetRequestSchema,
passwordResetConfirmSchema,
} from '../utils/validators';
const router = Router();
// POST /auth/signup
router.post('/signup', async (req, res: Response, next: NextFunction) => {
try {
const { email, password, name } = signupSchema.parse(req.body);
const result = await authService.signup(email, password, name);
res.status(201).json(result);
} catch (err) {
next(err);
}
});
// POST /auth/signin
router.post('/signin', async (req, res: Response, next: NextFunction) => {
try {
const { email, password } = signinSchema.parse(req.body);
const result = await authService.signin(email, password);
res.status(200).json(result);
} catch (err: unknown) {
if (
err instanceof Error &&
(err as { code?: string }).code === 'ACCOUNT_PENDING_DELETION'
) {
const appErr = err as { code?: string; statusCode?: number; deletion_scheduled_at?: string };
res.status(403).json({
error: err.message,
code: appErr.code,
deletion_scheduled_at: appErr.deletion_scheduled_at,
timestamp: new Date().toISOString(),
});
return;
}
next(err);
}
});
// POST /auth/google
router.post('/google', async (req, res: Response, next: NextFunction) => {
try {
const { id_token } = googleAuthSchema.parse(req.body);
const result = await authService.googleAuth(id_token);
const statusCode = result.is_new_user ? 201 : 200;
res.status(statusCode).json(result);
} catch (err: unknown) {
if (
err instanceof Error &&
(err as { code?: string }).code === 'ACCOUNT_PENDING_DELETION'
) {
const appErr = err as { code?: string; statusCode?: number; deletion_scheduled_at?: string };
res.status(403).json({
error: err.message,
code: appErr.code,
deletion_scheduled_at: appErr.deletion_scheduled_at,
timestamp: new Date().toISOString(),
});
return;
}
next(err);
}
});
// POST /auth/password-reset
router.post('/password-reset', async (req, res: Response, next: NextFunction) => {
try {
const { email } = passwordResetRequestSchema.parse(req.body);
await authService.requestPasswordReset(email);
res.status(200).json({
message: 'If an account exists with this email, a reset link has been sent.',
timestamp: new Date().toISOString(),
});
} catch (err) {
next(err);
}
});
// PUT /auth/password-reset
router.put('/password-reset', async (req, res: Response, next: NextFunction) => {
try {
const { token, new_password } = passwordResetConfirmSchema.parse(req.body);
await authService.confirmPasswordReset(token, new_password);
res.status(200).json({
message: 'Password updated successfully.',
timestamp: new Date().toISOString(),
});
} catch (err) {
next(err);
}
});
// DELETE /auth/account (protected)
router.delete(
'/account',
authMiddleware,
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
await authService.deleteAccount(req.userId!);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// POST /auth/restore-account (protected)
router.post(
'/restore-account',
authMiddleware,
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const user = await authService.restoreAccount(req.userId!);
res.status(200).json({
user,
message: 'Account restored successfully.',
timestamp: new Date().toISOString(),
});
} catch (err) {
next(err);
}
}
);
export default router;

View File

@@ -1,51 +0,0 @@
import { Router, Response, NextFunction } from 'express';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
import { pantryService } from '../services/pantryService';
import { addPantryItemSchema, updatePantryItemSchema } from '../utils/validators';
const router = Router();
router.use(authMiddleware);
// GET /pantry
router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const items = await pantryService.getItems(req.userId!);
res.status(200).json({ items, synced_at: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// POST /pantry
router.post('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const { item_name, quantity } = addPantryItemSchema.parse(req.body);
const item = await pantryService.addItem(req.userId!, item_name, quantity);
res.status(201).json({ item, synced_at: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// PUT /pantry/:item_id
router.put('/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const { quantity } = updatePantryItemSchema.parse(req.body);
const item = await pantryService.updateItem(req.userId!, req.params.item_id, quantity);
res.status(200).json({ item, synced_at: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// DELETE /pantry/:item_id
router.delete('/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
await pantryService.deleteItem(req.userId!, req.params.item_id);
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -1,31 +0,0 @@
import { Router, Response, NextFunction } from 'express';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
import { recipeService } from '../services/recipeService';
import { recipeQuerySchema, recipeDetailQuerySchema } from '../utils/validators';
const router = Router();
router.use(authMiddleware);
// GET /recipes
router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const { filter, page, limit, search } = recipeQuerySchema.parse(req.query);
const result = await recipeService.getRecipes(req.userId!, filter, page, limit, search);
res.status(200).json({ ...result, synced_at: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// GET /recipes/:recipe_id
router.get('/:recipe_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const { scale } = recipeDetailQuerySchema.parse(req.query);
const recipe = await recipeService.getRecipeById(req.params.recipe_id, req.userId!, scale);
res.status(200).json({ recipe });
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -1,118 +0,0 @@
import { Router, Response, NextFunction } from 'express';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
import { shoppingListService } from '../services/shoppingListService';
import {
createShoppingListSchema,
addShoppingListItemSchema,
updateShoppingListItemSchema,
addRecipesToListSchema,
} from '../utils/validators';
const router = Router();
router.use(authMiddleware);
// GET /shopping-lists
router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const lists = await shoppingListService.getLists(req.userId!);
res.status(200).json({ shopping_lists: lists, synced_at: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// POST /shopping-lists
router.post('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const { list_name } = createShoppingListSchema.parse(req.body);
const list = await shoppingListService.createList(req.userId!, list_name);
res.status(201).json({ shopping_list: list });
} catch (err) {
next(err);
}
});
// GET /shopping-lists/:list_id
router.get('/:list_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const list = await shoppingListService.getListById(req.params.list_id, req.userId!);
res.status(200).json({ shopping_list: list, synced_at: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// DELETE /shopping-lists/:list_id
router.delete('/:list_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
await shoppingListService.deleteList(req.params.list_id, req.userId!);
res.status(204).send();
} catch (err) {
next(err);
}
});
// POST /shopping-lists/:list_id/items
router.post('/:list_id/items', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const { item_name, quantity, unit } = addShoppingListItemSchema.parse(req.body);
const result = await shoppingListService.addItem(
req.params.list_id,
req.userId!,
item_name,
quantity,
unit
);
res.status(201).json(result);
} catch (err) {
next(err);
}
});
// POST /shopping-lists/:list_id/add-recipes
router.post('/:list_id/add-recipes', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const { recipe_ids, scale_factor } = addRecipesToListSchema.parse(req.body);
const result = await shoppingListService.addRecipesToList(
req.params.list_id,
req.userId!,
recipe_ids,
scale_factor
);
res.status(201).json(result);
} catch (err) {
next(err);
}
});
// PUT /shopping-lists/:list_id/items/:item_id
router.put('/:list_id/items/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const updates = updateShoppingListItemSchema.parse(req.body);
const item = await shoppingListService.updateItem(
req.params.list_id,
req.params.item_id,
req.userId!,
updates
);
res.status(200).json({ item, synced_at: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// DELETE /shopping-lists/:list_id/items/:item_id
router.delete('/:list_id/items/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
await shoppingListService.deleteItem(
req.params.list_id,
req.params.item_id,
req.userId!
);
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -1,20 +0,0 @@
import { Router, Response, NextFunction } from 'express';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
import { syncService } from '../services/syncService';
import { syncQuerySchema } from '../utils/validators';
const router = Router();
router.use(authMiddleware);
// GET /sync
router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const { since } = syncQuerySchema.parse(req.query);
const delta = await syncService.getDelta(req.userId!, since);
res.status(200).json(delta);
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -1,24 +0,0 @@
import { createApp } from './app';
import { config } from './config/env';
import { logger } from './utils/logger';
import { startCronJobs } from './jobs/cronJobs';
import db from './db/connection';
async function main() {
// Verify DB connection
await db.raw('SELECT 1');
logger.info('Database connection established.');
const app = createApp();
app.listen(config.port, () => {
logger.info(`Pantree API running on port ${config.port}`);
});
startCronJobs();
}
main().catch((err) => {
logger.error({ err }, 'Failed to start server');
process.exit(1);
});

View File

@@ -1,254 +0,0 @@
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { OAuth2Client } from 'google-auth-library';
import db from '../db/connection';
import { config } from '../config/env';
import { signToken } from '../utils/jwt';
import { createError } from '../middleware/errorHandler';
import { BCRYPT_ROUNDS, ACCOUNT_DELETION_DAYS, PASSWORD_RESET_EXPIRES_HOURS } from '../config/constants';
import { emailService } from './emailService';
const googleClient = new OAuth2Client(config.googleClientId);
function formatUser(user: Record<string, unknown>) {
return {
id: user.id,
email: user.email,
name: user.name,
profile_picture_url: user.profile_picture_url ?? null,
deleted_at: user.deleted_at ?? null,
created_at: user.created_at,
};
}
export const authService = {
async signup(email: string, password: string, name: string) {
const existing = await db('users')
.whereRaw('LOWER(email) = LOWER(?)', [email])
.first();
if (existing) {
throw createError('Email already registered.', 409, 'CONFLICT');
}
const password_hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const [user] = await db('users')
.insert({
email: email.toLowerCase(),
password_hash,
name,
})
.returning('*');
const { token, expiresAt } = signToken(user.id);
return {
user: formatUser(user),
token,
expires_at: expiresAt.toISOString(),
};
},
async signin(email: string, password: string) {
const user = await db('users')
.whereRaw('LOWER(email) = LOWER(?)', [email])
.first();
if (!user) {
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
}
if (!user.password_hash) {
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
}
if (user.deleted_at) {
const err = createError('Account is pending deletion.', 403, 'ACCOUNT_PENDING_DELETION');
(err as Record<string, unknown>).deletion_scheduled_at = user.deletion_scheduled_at;
throw err;
}
const { token, expiresAt } = signToken(user.id);
return {
user: formatUser(user),
token,
expires_at: expiresAt.toISOString(),
};
},
async googleAuth(idToken: string) {
let ticket;
try {
ticket = await googleClient.verifyIdToken({
idToken,
audience: config.googleClientId,
});
} catch {
throw createError('Google token verification failed.', 401, 'INVALID_GOOGLE_TOKEN');
}
const payload = ticket.getPayload();
if (!payload || !payload.email) {
throw createError('Google token verification failed.', 401, 'INVALID_GOOGLE_TOKEN');
}
const { sub: googleId, email, name = '', picture } = payload;
// Check for existing user by google_id or email
let user = await db('users')
.where({ google_id: googleId })
.orWhereRaw('LOWER(email) = LOWER(?)', [email])
.first();
if (user && user.deleted_at) {
const err = createError('Account is pending deletion.', 403, 'ACCOUNT_PENDING_DELETION');
(err as Record<string, unknown>).deletion_scheduled_at = user.deletion_scheduled_at;
throw err;
}
let isNewUser = false;
if (!user) {
[user] = await db('users')
.insert({
email: email.toLowerCase(),
google_id: googleId,
name,
profile_picture_url: picture ?? null,
email_verified: true,
})
.returning('*');
isNewUser = true;
} else if (!user.google_id) {
// Link google account to existing email account
[user] = await db('users')
.where({ id: user.id })
.update({
google_id: googleId,
profile_picture_url: user.profile_picture_url ?? picture ?? null,
email_verified: true,
updated_at: db.fn.now(),
})
.returning('*');
}
const { token, expiresAt } = signToken(user.id);
return {
user: formatUser(user),
token,
expires_at: expiresAt.toISOString(),
is_new_user: isNewUser,
};
},
async requestPasswordReset(email: string) {
const user = await db('users')
.whereRaw('LOWER(email) = LOWER(?)', [email])
.whereNull('deleted_at')
.first();
// Always return success — prevents email enumeration
if (!user) return;
// Invalidate any existing unused tokens
await db('password_reset_tokens')
.where({ user_id: user.id })
.whereNull('used_at')
.update({ used_at: db.fn.now() });
const rawToken = crypto.randomBytes(32).toString('hex');
const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS);
const expires_at = new Date(
Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000
).toISOString();
await db('password_reset_tokens').insert({
user_id: user.id,
token_hash,
expires_at,
});
await emailService.sendPasswordReset(user.email, rawToken);
},
async confirmPasswordReset(rawToken: string, newPassword: string) {
// Find all unexpired, unused tokens and check each
const tokens = await db('password_reset_tokens')
.whereNull('used_at')
.where('expires_at', '>', db.fn.now())
.orderBy('created_at', 'desc');
let matchedToken = null;
for (const t of tokens) {
const match = await bcrypt.compare(rawToken, t.token_hash);
if (match) {
matchedToken = t;
break;
}
}
if (!matchedToken) {
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
}
const password_hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
await db.transaction(async (trx) => {
await trx('users')
.where({ id: matchedToken.user_id })
.update({ password_hash, updated_at: trx.fn.now() });
await trx('password_reset_tokens')
.where({ id: matchedToken.id })
.update({ used_at: trx.fn.now() });
});
},
async deleteAccount(userId: string) {
const deletionScheduledAt = new Date(
Date.now() + ACCOUNT_DELETION_DAYS * 24 * 60 * 60 * 1000
).toISOString();
await db('users').where({ id: userId }).update({
deleted_at: db.fn.now(),
deletion_scheduled_at: deletionScheduledAt,
updated_at: db.fn.now(),
});
},
async restoreAccount(userId: string) {
const user = await db('users').where({ id: userId }).first();
if (!user) {
throw createError('Account not found.', 410, 'GONE');
}
if (!user.deleted_at) {
// Not deleted — nothing to restore, just return user
return formatUser(user);
}
if (user.deletion_scheduled_at && new Date(user.deletion_scheduled_at) <= new Date()) {
throw createError('Account restoration window has expired.', 410, 'GONE');
}
const [restored] = await db('users')
.where({ id: userId })
.update({
deleted_at: null,
deletion_scheduled_at: null,
updated_at: db.fn.now(),
})
.returning('*');
return formatUser(restored);
},
};

View File

@@ -1,29 +0,0 @@
import { config } from '../config/env';
import { logger } from '../utils/logger';
export const emailService = {
async sendPasswordReset(toEmail: string, rawToken: string): Promise<void> {
const resetUrl = `${config.passwordResetUrl}?token=${rawToken}`;
if (config.isTest || !config.sendgridApiKey) {
logger.info({ toEmail, resetUrl }, 'Password reset email (not sent in test/dev without key)');
return;
}
try {
const sgMail = await import('@sendgrid/mail');
sgMail.default.setApiKey(config.sendgridApiKey);
await sgMail.default.send({
to: toEmail,
from: config.sendgridFromEmail,
subject: 'Reset your Pantree password',
text: `Click the link to reset your password: ${resetUrl}\n\nThis link expires in 1 hour.`,
html: `<p>Click the link to reset your password:</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>This link expires in 1 hour.</p>`,
});
} catch (err) {
logger.error({ err }, 'Failed to send password reset email');
// Do not throw — prevents email enumeration via timing
}
},
};

View File

@@ -1,73 +0,0 @@
import db from '../db/connection';
import { createError } from '../middleware/errorHandler';
export const pantryService = {
async getItems(userId: string) {
const items = await db('pantry_items')
.where({ user_id: userId })
.select('id', 'item_name', 'quantity', 'last_modified', 'created_at')
.orderBy('item_name_lower', 'asc');
return items;
},
async addItem(userId: string, itemName: string, quantity: number) {
const existing = await db('pantry_items')
.where({ user_id: userId, item_name_lower: itemName.toLowerCase() })
.first();
if (existing) {
throw createError(
`Item '${itemName}' already exists in your pantry.`,
409,
'DUPLICATE_ITEM'
);
}
const [item] = await db('pantry_items')
.insert({
user_id: userId,
item_name: itemName,
item_name_lower: itemName.toLowerCase(),
quantity,
last_modified: db.fn.now(),
})
.returning(['id', 'item_name', 'quantity', 'last_modified', 'created_at']);
return item;
},
async updateItem(userId: string, itemId: string, quantity: number) {
const [item] = await db('pantry_items')
.where({ id: itemId, user_id: userId })
.update({
quantity,
last_modified: db.fn.now(),
updated_at: db.fn.now(),
})
.returning(['id', 'item_name', 'quantity', 'last_modified', 'created_at']);
if (!item) {
throw createError('Pantry item not found.', 404, 'NOT_FOUND');
}
return item;
},
async deleteItem(userId: string, itemId: string) {
const deleted = await db('pantry_items')
.where({ id: itemId, user_id: userId })
.delete();
if (!deleted) {
throw createError('Pantry item not found.', 404, 'NOT_FOUND');
}
// Record tombstone for sync
await db('deleted_records').insert({
user_id: userId,
table_name: 'pantry_items',
record_id: itemId,
});
},
};

View File

@@ -1,136 +0,0 @@
import db from '../db/connection';
import { createError } from '../middleware/errorHandler';
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '../config/constants';
export const recipeService = {
async getRecipes(
userId: string,
filter: 'all' | 'can_make' | 'can_partially_make',
page: number,
limit: number,
search?: string
) {
const safeLimit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * safeLimit;
// Get user's pantry item names (lowercase)
const pantryItems = await db('pantry_items')
.where({ user_id: userId })
.pluck('item_name_lower');
const pantrySet = new Set(pantryItems);
// Base query
let query = db('recipes').select('recipes.*');
if (search) {
query = query.whereRaw('LOWER(recipes.name) LIKE ?', [`%${search.toLowerCase()}%`]);
}
const allRecipes = await query.orderBy('recipes.name', 'asc');
// Get ingredients for all recipes in one query
const recipeIds = allRecipes.map((r) => r.id);
const allIngredients = recipeIds.length
? await db('recipe_ingredients').whereIn('recipe_id', recipeIds)
: [];
const ingredientsByRecipe = new Map<string, typeof allIngredients>();
for (const ing of allIngredients) {
if (!ingredientsByRecipe.has(ing.recipe_id)) {
ingredientsByRecipe.set(ing.recipe_id, []);
}
ingredientsByRecipe.get(ing.recipe_id)!.push(ing);
}
// Compute availability for each recipe
const enriched = allRecipes.map((recipe) => {
const ingredients = ingredientsByRecipe.get(recipe.id) ?? [];
const ingredientCount = ingredients.length;
const availableCount = ingredients.filter((i) =>
pantrySet.has(i.item_name_lower)
).length;
const canMake = ingredientCount > 0 && availableCount === ingredientCount;
const canPartiallyMake = availableCount > 0 && availableCount < ingredientCount;
const availabilityPct =
ingredientCount > 0
? parseFloat(((availableCount / ingredientCount) * 100).toFixed(2))
: 0;
return {
id: recipe.id,
name: recipe.name,
servings: recipe.servings,
ingredient_count: ingredientCount,
available_ingredient_count: availableCount,
can_make: canMake,
can_partially_make: canPartiallyMake,
availability_percentage: availabilityPct,
};
});
// Apply filter
let filtered = enriched;
if (filter === 'can_make') {
filtered = enriched.filter((r) => r.can_make);
} else if (filter === 'can_partially_make') {
filtered = enriched.filter((r) => r.can_partially_make);
}
const total = filtered.length;
const totalPages = Math.ceil(total / safeLimit);
const paginated = filtered.slice(offset, offset + safeLimit);
return {
recipes: paginated,
pagination: {
page,
limit: safeLimit,
total,
total_pages: totalPages,
},
};
},
async getRecipeById(recipeId: string, userId: string, scaleFactor: number) {
const recipe = await db('recipes').where({ id: recipeId }).first();
if (!recipe) {
throw createError('Recipe not found.', 404, 'NOT_FOUND');
}
const ingredients = await db('recipe_ingredients').where({ recipe_id: recipeId });
const pantryItems = await db('pantry_items')
.where({ user_id: userId })
.pluck('item_name_lower');
const pantrySet = new Set(pantryItems);
const scaledIngredients = ingredients.map((ing) => ({
id: ing.id,
item_name: ing.item_name,
quantity: parseFloat((parseFloat(ing.quantity) * scaleFactor).toFixed(4)),
original_quantity: parseFloat(ing.quantity),
unit: ing.unit,
in_pantry: pantrySet.has(ing.item_name_lower),
}));
const availableCount = scaledIngredients.filter((i) => i.in_pantry).length;
const canMake =
scaledIngredients.length > 0 && availableCount === scaledIngredients.length;
return {
id: recipe.id,
name: recipe.name,
servings: recipe.servings,
scaled_servings: recipe.servings * scaleFactor,
scale_factor: scaleFactor,
instructions: recipe.instructions,
ingredients: scaledIngredients,
can_make: canMake,
available_ingredient_count: availableCount,
ingredient_count: scaledIngredients.length,
};
},
};

View File

@@ -1,267 +0,0 @@
import db from '../db/connection';
import { createError } from '../middleware/errorHandler';
async function getListForUser(listId: string, userId: string) {
const list = await db('shopping_lists')
.where({ id: listId, user_id: userId })
.first();
if (!list) {
throw createError('Shopping list not found.', 404, 'NOT_FOUND');
}
return list;
}
export const shoppingListService = {
async getLists(userId: string) {
const lists = await db('shopping_lists')
.where({ user_id: userId })
.select('id', 'list_name', 'last_modified', 'created_at')
.orderBy('created_at', 'desc');
// Get item counts in one query
const listIds = lists.map((l) => l.id);
const counts = listIds.length
? await db('shopping_list_items')
.whereIn('shopping_list_id', listIds)
.select('shopping_list_id')
.count('id as item_count')
.sum(db.raw('CASE WHEN checked_off THEN 1 ELSE 0 END as checked_count'))
.groupBy('shopping_list_id')
: [];
const countMap = new Map(
counts.map((c) => [
c.shopping_list_id,
{ item_count: parseInt(String(c.item_count), 10), checked_count: parseInt(String(c.checked_count), 10) },
])
);
return lists.map((l) => ({
...l,
item_count: countMap.get(l.id)?.item_count ?? 0,
checked_count: countMap.get(l.id)?.checked_count ?? 0,
}));
},
async createList(userId: string, listName: string) {
const [list] = await db('shopping_lists')
.insert({ user_id: userId, list_name: listName })
.returning(['id', 'list_name', 'last_modified', 'created_at']);
return { ...list, item_count: 0, checked_count: 0 };
},
async getListById(listId: string, userId: string) {
const list = await getListForUser(listId, userId);
const items = await db('shopping_list_items')
.where({ shopping_list_id: listId })
.select('id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified')
.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,
};
},
async deleteList(listId: string, userId: string) {
const deleted = await db('shopping_lists')
.where({ id: listId, user_id: userId })
.delete();
if (!deleted) {
throw createError('Shopping list not found.', 404, 'NOT_FOUND');
}
await db('deleted_records').insert({
user_id: userId,
table_name: 'shopping_lists',
record_id: listId,
});
},
async addItem(
listId: string,
userId: string,
itemName: string,
quantity: number,
unit: string
) {
await getListForUser(listId, userId);
const existing = await db('shopping_list_items')
.where({
shopping_list_id: listId,
item_name_lower: itemName.toLowerCase(),
unit,
})
.first();
if (existing) {
const previousQuantity = parseFloat(existing.quantity);
const newQuantity = previousQuantity + quantity;
const [updated] = await db('shopping_list_items')
.where({ id: existing.id })
.update({
quantity: newQuantity,
last_modified: db.fn.now(),
updated_at: db.fn.now(),
})
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
// Update list last_modified
await db('shopping_lists')
.where({ id: listId })
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
return { item: updated, merged: true, previous_quantity: previousQuantity };
}
const [item] = await db('shopping_list_items')
.insert({
shopping_list_id: listId,
item_name: itemName,
item_name_lower: itemName.toLowerCase(),
quantity,
unit,
last_modified: db.fn.now(),
})
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
await db('shopping_lists')
.where({ id: listId })
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
return { item, merged: false };
},
async addRecipesToList(
listId: string,
userId: string,
recipeIds: string[],
scaleFactor: number
) {
await getListForUser(listId, userId);
// Validate all recipes exist
const recipes = await db('recipes').whereIn('id', recipeIds).select('id');
if (recipes.length !== recipeIds.length) {
throw createError('One or more recipes not found.', 404, 'NOT_FOUND');
}
const ingredients = await db('recipe_ingredients').whereIn('recipe_id', recipeIds);
let itemsMerged = 0;
let itemsCreated = 0;
await db.transaction(async (trx) => {
for (const ing of ingredients) {
const scaledQty = parseFloat(ing.quantity) * scaleFactor;
const existing = await trx('shopping_list_items')
.where({
shopping_list_id: listId,
item_name_lower: ing.item_name_lower,
unit: ing.unit,
})
.first();
if (existing) {
await trx('shopping_list_items')
.where({ id: existing.id })
.update({
quantity: parseFloat(existing.quantity) + scaledQty,
last_modified: trx.fn.now(),
updated_at: trx.fn.now(),
});
itemsMerged++;
} else {
await trx('shopping_list_items').insert({
shopping_list_id: listId,
item_name: ing.item_name,
item_name_lower: ing.item_name_lower,
quantity: scaledQty,
unit: ing.unit,
last_modified: trx.fn.now(),
});
itemsCreated++;
}
}
await trx('shopping_lists')
.where({ id: listId })
.update({ last_modified: trx.fn.now(), updated_at: trx.fn.now() });
});
const updatedList = await this.getListById(listId, userId);
return {
shopping_list: updatedList,
recipes_added: recipeIds.length,
items_merged: itemsMerged,
items_created: itemsCreated,
};
},
async updateItem(
listId: string,
itemId: string,
userId: string,
updates: { quantity?: number; unit?: string; checked_off?: boolean }
) {
await getListForUser(listId, userId);
const updateData: Record<string, unknown> = {
last_modified: db.fn.now(),
updated_at: db.fn.now(),
};
if (updates.quantity !== undefined) updateData.quantity = updates.quantity;
if (updates.unit !== undefined) updateData.unit = updates.unit;
if (updates.checked_off !== undefined) updateData.checked_off = updates.checked_off;
const [item] = await db('shopping_list_items')
.where({ id: itemId, shopping_list_id: listId })
.update(updateData)
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
if (!item) {
throw createError('Shopping list item not found.', 404, 'NOT_FOUND');
}
await db('shopping_lists')
.where({ id: listId })
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
return item;
},
async deleteItem(listId: string, itemId: string, userId: string) {
await getListForUser(listId, userId);
const deleted = await db('shopping_list_items')
.where({ id: itemId, shopping_list_id: listId })
.delete();
if (!deleted) {
throw createError('Shopping list item not found.', 404, 'NOT_FOUND');
}
await db('deleted_records').insert({
user_id: userId,
table_name: 'shopping_list_items',
record_id: itemId,
});
await db('shopping_lists')
.where({ id: listId })
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
},
};

Some files were not shown because too many files have changed in this diff Show More