Compare commits
1 Commits
feature/an
...
feature/fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42497a368 |
21
.env.example
21
.env.example
@@ -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
6
.gitignore
vendored
@@ -1,6 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
.env
|
|
||||||
*.log
|
|
||||||
coverage/
|
|
||||||
.DS_Store
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
102
android/app/src/main/java/com/pantree/app/data/local/dao/Daos.kt
Normal file
102
android/app/src/main/java/com/pantree/app/data/local/dao/Daos.kt
Normal 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()
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
699
android/app/src/main/java/com/pantree/app/ui/auth/AuthScreens.kt
Normal file
699
android/app/src/main/java/com/pantree/app/ui/auth/AuthScreens.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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." }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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") } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('.')
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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") }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
@@ -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") } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
@@ -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") } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">Pantree</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="Theme.Pantree" parent="android:Theme.Material.Light.NoActionBar" />
|
|
||||||
</resources>
|
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -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']
|
|
||||||
};
|
|
||||||
49
package.json
49
package.json
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
42
src/app.ts
42
src/app.ts
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import knex from 'knex';
|
|
||||||
import knexConfig from './knexfile';
|
|
||||||
|
|
||||||
const db = knex(knexConfig);
|
|
||||||
|
|
||||||
export default db;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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
Reference in New Issue
Block a user