Compare commits
2 Commits
feature/an
...
41fd933642
| Author | SHA1 | Date | |
|---|---|---|---|
| 41fd933642 | |||
|
|
e10b387be6 |
@@ -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,128 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'com.android.application'
|
|
||||||
id 'org.jetbrains.kotlin.android'
|
|
||||||
id 'com.google.dagger.hilt.android'
|
|
||||||
id 'com.google.devtools.ksp'
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace 'com.pantree.app'
|
|
||||||
compileSdk 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.pantree.app"
|
|
||||||
minSdk 26
|
|
||||||
targetSdk 34
|
|
||||||
versionCode 1
|
|
||||||
versionName "1.0.0"
|
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
vectorDrawables {
|
|
||||||
useSupportLibrary true
|
|
||||||
}
|
|
||||||
|
|
||||||
buildConfigField "String", "BASE_URL", '"https://api.pantree.app/v1/"'
|
|
||||||
buildConfigField "String", "GOOGLE_WEB_CLIENT_ID", '"YOUR_GOOGLE_WEB_CLIENT_ID"'
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled true
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
buildConfigField "String", "BASE_URL", '"http://10.0.2.2:3000/v1/"'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '17'
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
compose true
|
|
||||||
buildConfig true
|
|
||||||
}
|
|
||||||
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion '1.5.11'
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
|
||||||
resources {
|
|
||||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// Core
|
|
||||||
implementation 'androidx.core:core-ktx:1.13.0'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
|
||||||
implementation 'androidx.activity:activity-compose:1.9.0'
|
|
||||||
|
|
||||||
// Compose BOM
|
|
||||||
implementation platform('androidx.compose:compose-bom:2024.04.01')
|
|
||||||
implementation 'androidx.compose.ui:ui'
|
|
||||||
implementation 'androidx.compose.ui:ui-graphics'
|
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
|
||||||
implementation 'androidx.compose.material3:material3'
|
|
||||||
implementation 'androidx.compose.material:material-icons-extended'
|
|
||||||
|
|
||||||
// Navigation
|
|
||||||
implementation 'androidx.navigation:navigation-compose:2.7.7'
|
|
||||||
|
|
||||||
// Hilt DI
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.51.1'
|
|
||||||
ksp 'com.google.dagger:hilt-android-compiler:2.51.1'
|
|
||||||
implementation 'androidx.hilt:hilt-navigation-compose:1.2.0'
|
|
||||||
|
|
||||||
// Room
|
|
||||||
implementation 'androidx.room:room-runtime:2.6.1'
|
|
||||||
implementation 'androidx.room:room-ktx:2.6.1'
|
|
||||||
ksp 'androidx.room:room-compiler:2.6.1'
|
|
||||||
|
|
||||||
// Retrofit + OkHttp
|
|
||||||
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
|
|
||||||
implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
|
||||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
|
||||||
|
|
||||||
// Gson
|
|
||||||
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'
|
|
||||||
|
|
||||||
// Google Identity (One Tap)
|
|
||||||
implementation 'com.google.android.gms:play-services-auth:21.1.1'
|
|
||||||
|
|
||||||
// Coil (image loading)
|
|
||||||
implementation 'io.coil-kt:coil-compose:2.6.0'
|
|
||||||
|
|
||||||
// DataStore
|
|
||||||
implementation 'androidx.datastore:datastore-preferences:1.1.0'
|
|
||||||
|
|
||||||
// Coroutines
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
|
|
||||||
|
|
||||||
// Testing
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
|
||||||
testImplementation 'io.mockk:mockk:1.13.10'
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
|
||||||
androidTestImplementation platform('androidx.compose:compose-bom:2024.04.01')
|
|
||||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
|
||||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:name=".PantreeApplication"
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.Pantree"
|
|
||||||
android:usesCleartextTraffic="false">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:theme="@style/Theme.Pantree">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Deep link for password reset -->
|
|
||||||
<intent-filter android:autoVerify="true">
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data
|
|
||||||
android:scheme="https"
|
|
||||||
android:host="pantree.app"
|
|
||||||
android:pathPrefix="/reset-password" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.pantree.app
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import com.pantree.app.ui.navigation.PantreeNavGraph
|
|
||||||
import com.pantree.app.ui.theme.PantreeTheme
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
setContent {
|
|
||||||
PantreeTheme {
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
|
||||||
PantreeNavGraph()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.pantree.app
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
|
||||||
class PantreeApplication : Application()
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.pantree.app.data.local
|
|
||||||
|
|
||||||
import androidx.room.Database
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
import com.pantree.app.data.local.dao.PantryDao
|
|
||||||
import com.pantree.app.data.local.dao.RecipeDao
|
|
||||||
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(
|
|
||||||
entities = [
|
|
||||||
PantryItemEntity::class,
|
|
||||||
RecipeEntity::class,
|
|
||||||
RecipeIngredientEntity::class,
|
|
||||||
ShoppingListEntity::class,
|
|
||||||
ShoppingListItemEntity::class
|
|
||||||
],
|
|
||||||
version = 1,
|
|
||||||
exportSchema = false
|
|
||||||
)
|
|
||||||
abstract class PantreeDatabase : RoomDatabase() {
|
|
||||||
abstract fun pantryDao(): PantryDao
|
|
||||||
abstract fun recipeDao(): RecipeDao
|
|
||||||
abstract fun shoppingListDao(): ShoppingListDao
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package com.pantree.app.data.local.entity
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.Index
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
@Entity(tableName = "pantry_items")
|
|
||||||
data class PantryItemEntity(
|
|
||||||
@PrimaryKey val id: String,
|
|
||||||
@ColumnInfo(name = "item_name") val itemName: String,
|
|
||||||
val quantity: Int,
|
|
||||||
@ColumnInfo(name = "last_modified") val lastModified: String,
|
|
||||||
@ColumnInfo(name = "created_at") val createdAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(tableName = "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")
|
|
||||||
data class ShoppingListEntity(
|
|
||||||
@PrimaryKey val id: String,
|
|
||||||
@ColumnInfo(name = "list_name") val listName: String,
|
|
||||||
@ColumnInfo(name = "item_count") val itemCount: Int,
|
|
||||||
@ColumnInfo(name = "checked_count") val checkedCount: Int,
|
|
||||||
@ColumnInfo(name = "last_modified") val lastModified: String,
|
|
||||||
@ColumnInfo(name = "created_at") val createdAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "shopping_list_items",
|
|
||||||
foreignKeys = [ForeignKey(
|
|
||||||
entity = ShoppingListEntity::class,
|
|
||||||
parentColumns = ["id"],
|
|
||||||
childColumns = ["shopping_list_id"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
)],
|
|
||||||
indices = [Index("shopping_list_id")]
|
|
||||||
)
|
|
||||||
data class ShoppingListItemEntity(
|
|
||||||
@PrimaryKey val id: String,
|
|
||||||
@ColumnInfo(name = "shopping_list_id") val shoppingListId: String,
|
|
||||||
@ColumnInfo(name = "item_name") val itemName: String,
|
|
||||||
val quantity: Double,
|
|
||||||
val unit: String,
|
|
||||||
@ColumnInfo(name = "checked_off") val checkedOff: Boolean,
|
|
||||||
@ColumnInfo(name = "last_modified") val lastModified: String
|
|
||||||
)
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
package com.pantree.app.data.model
|
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
data class SignupRequest(
|
|
||||||
val email: String,
|
|
||||||
val password: String,
|
|
||||||
val name: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SigninRequest(
|
|
||||||
val email: String,
|
|
||||||
val password: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GoogleAuthRequest(
|
|
||||||
@SerializedName("id_token") val idToken: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PasswordResetRequest(
|
|
||||||
val email: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ConfirmPasswordResetRequest(
|
|
||||||
val token: String,
|
|
||||||
@SerializedName("new_password") val newPassword: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AuthResponse(
|
|
||||||
val user: UserDto,
|
|
||||||
val token: String,
|
|
||||||
@SerializedName("expires_at") val expiresAt: String,
|
|
||||||
@SerializedName("is_new_user") val isNewUser: Boolean? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UserDto(
|
|
||||||
val id: String,
|
|
||||||
val email: String,
|
|
||||||
val name: String,
|
|
||||||
@SerializedName("profile_picture_url") val profilePictureUrl: String?,
|
|
||||||
@SerializedName("deleted_at") val deletedAt: String?,
|
|
||||||
@SerializedName("created_at") val createdAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MessageResponse(
|
|
||||||
val message: String,
|
|
||||||
val timestamp: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RestoreAccountResponse(
|
|
||||||
val user: UserDto,
|
|
||||||
val message: String,
|
|
||||||
val timestamp: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ApiError(
|
|
||||||
val error: String,
|
|
||||||
val code: String,
|
|
||||||
val timestamp: String,
|
|
||||||
@SerializedName("deletion_scheduled_at") val deletionScheduledAt: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Pantry ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
data class AddPantryItemRequest(
|
|
||||||
@SerializedName("item_name") val itemName: String,
|
|
||||||
val quantity: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UpdatePantryItemRequest(
|
|
||||||
val quantity: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PantryItemDto(
|
|
||||||
val id: String,
|
|
||||||
@SerializedName("item_name") val itemName: String,
|
|
||||||
val quantity: Int,
|
|
||||||
@SerializedName("last_modified") val lastModified: String,
|
|
||||||
@SerializedName("created_at") val createdAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PantryListResponse(
|
|
||||||
val items: List<PantryItemDto>,
|
|
||||||
@SerializedName("synced_at") val syncedAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PantryItemResponse(
|
|
||||||
val item: PantryItemDto,
|
|
||||||
@SerializedName("synced_at") val syncedAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Recipes ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
data class RecipeSummaryDto(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val servings: Int,
|
|
||||||
@SerializedName("ingredient_count") val ingredientCount: Int,
|
|
||||||
@SerializedName("available_ingredient_count") val availableIngredientCount: Int,
|
|
||||||
@SerializedName("can_make") val canMake: Boolean,
|
|
||||||
@SerializedName("availability_percentage") val availabilityPercentage: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RecipeIngredientDto(
|
|
||||||
val id: String,
|
|
||||||
@SerializedName("item_name") val itemName: String,
|
|
||||||
val quantity: Double,
|
|
||||||
@SerializedName("original_quantity") val originalQuantity: Double,
|
|
||||||
val unit: String,
|
|
||||||
@SerializedName("in_pantry") val inPantry: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RecipeDetailDto(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val servings: Int,
|
|
||||||
@SerializedName("scaled_servings") val scaledServings: Int,
|
|
||||||
@SerializedName("scale_factor") val scaleFactor: Int,
|
|
||||||
val instructions: String,
|
|
||||||
val ingredients: List<RecipeIngredientDto>,
|
|
||||||
@SerializedName("can_make") val canMake: Boolean,
|
|
||||||
@SerializedName("available_ingredient_count") val availableIngredientCount: Int,
|
|
||||||
@SerializedName("ingredient_count") val ingredientCount: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaginationDto(
|
|
||||||
val page: Int,
|
|
||||||
val limit: Int,
|
|
||||||
val total: Int,
|
|
||||||
@SerializedName("total_pages") val totalPages: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RecipeListResponse(
|
|
||||||
val recipes: List<RecipeSummaryDto>,
|
|
||||||
val pagination: PaginationDto,
|
|
||||||
@SerializedName("synced_at") val syncedAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RecipeDetailResponse(
|
|
||||||
val recipe: RecipeDetailDto
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 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(
|
|
||||||
val id: String,
|
|
||||||
@SerializedName("list_name") val listName: String,
|
|
||||||
@SerializedName("item_count") val itemCount: Int,
|
|
||||||
@SerializedName("checked_count") val checkedCount: Int,
|
|
||||||
@SerializedName("last_modified") val lastModified: String,
|
|
||||||
@SerializedName("created_at") val createdAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ShoppingListItemDto(
|
|
||||||
val id: String,
|
|
||||||
@SerializedName("item_name") val itemName: String,
|
|
||||||
val quantity: Double,
|
|
||||||
val unit: String,
|
|
||||||
@SerializedName("checked_off") val checkedOff: Boolean,
|
|
||||||
@SerializedName("last_modified") val lastModified: String,
|
|
||||||
val merged: Boolean? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ShoppingListDetailDto(
|
|
||||||
val id: String,
|
|
||||||
@SerializedName("list_name") val listName: String,
|
|
||||||
@SerializedName("last_modified") val lastModified: String,
|
|
||||||
@SerializedName("created_at") val createdAt: String,
|
|
||||||
val items: List<ShoppingListItemDto>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ShoppingListsResponse(
|
|
||||||
@SerializedName("shopping_lists") val shoppingLists: List<ShoppingListSummaryDto>,
|
|
||||||
@SerializedName("synced_at") val syncedAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ShoppingListResponse(
|
|
||||||
@SerializedName("shopping_list") val shoppingList: ShoppingListSummaryDto
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ShoppingListDetailResponse(
|
|
||||||
@SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto,
|
|
||||||
@SerializedName("synced_at") val syncedAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ShoppingListItemResponse(
|
|
||||||
val item: ShoppingListItemDto,
|
|
||||||
@SerializedName("synced_at") val syncedAt: String? = null,
|
|
||||||
val merged: Boolean? = null,
|
|
||||||
@SerializedName("previous_quantity") val previousQuantity: Double? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AddRecipesResponse(
|
|
||||||
@SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto,
|
|
||||||
@SerializedName("recipes_added") val recipesAdded: Int,
|
|
||||||
@SerializedName("items_merged") val itemsMerged: Int,
|
|
||||||
@SerializedName("items_created") val itemsCreated: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Sync ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
data class SyncPantryDto(
|
|
||||||
val updated: List<PantryItemDto>,
|
|
||||||
val deleted: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SyncListItemsDto(
|
|
||||||
val updated: List<ShoppingListItemDto>,
|
|
||||||
val deleted: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SyncShoppingListDto(
|
|
||||||
val id: String,
|
|
||||||
@SerializedName("list_name") val listName: String,
|
|
||||||
@SerializedName("last_modified") val lastModified: String,
|
|
||||||
val items: SyncListItemsDto
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SyncShoppingListsDto(
|
|
||||||
val updated: List<SyncShoppingListDto>,
|
|
||||||
val deleted: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SyncResponse(
|
|
||||||
@SerializedName("server_timestamp") val serverTimestamp: String,
|
|
||||||
val pantry: SyncPantryDto,
|
|
||||||
@SerializedName("shopping_lists") val shoppingLists: SyncShoppingListsDto
|
|
||||||
)
|
|
||||||
@@ -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,22 +0,0 @@
|
|||||||
package com.pantree.app.data.remote
|
|
||||||
|
|
||||||
import com.pantree.app.data.local.TokenStore
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class AuthInterceptor @Inject constructor(
|
|
||||||
private val tokenStore: TokenStore
|
|
||||||
) : Interceptor {
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val token = tokenStore.getToken()
|
|
||||||
val request = if (token != null) {
|
|
||||||
chain.request().newBuilder()
|
|
||||||
.addHeader("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
} else {
|
|
||||||
chain.request()
|
|
||||||
}
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package com.pantree.app.data.repository
|
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.pantree.app.data.model.ApiError
|
|
||||||
import com.pantree.app.data.model.AuthResponse
|
|
||||||
import com.pantree.app.data.model.ConfirmPasswordResetRequest
|
|
||||||
import com.pantree.app.data.model.GoogleAuthRequest
|
|
||||||
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.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class AuthRepository @Inject constructor(
|
|
||||||
private val api: ApiService,
|
|
||||||
private val tokenStore: TokenStore,
|
|
||||||
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)) }
|
|
||||||
if (result is Result.Success) {
|
|
||||||
tokenStore.saveToken(result.data.token, result.data.expiresAt)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun signin(email: String, password: String): Result<AuthResponse> {
|
|
||||||
val result = safeApiCall(gson) { api.signin(SigninRequest(email, password)) }
|
|
||||||
if (result is Result.Success) {
|
|
||||||
tokenStore.saveToken(result.data.token, result.data.expiresAt)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun googleAuth(idToken: String): Result<AuthResponse> {
|
|
||||||
val result = safeApiCall(gson) { api.googleAuth(GoogleAuthRequest(idToken)) }
|
|
||||||
if (result is Result.Success) {
|
|
||||||
tokenStore.saveToken(result.data.token, result.data.expiresAt)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun requestPasswordReset(email: String): Result<MessageResponse> =
|
|
||||||
safeApiCall(gson) { api.requestPasswordReset(PasswordResetRequest(email)) }
|
|
||||||
|
|
||||||
suspend fun confirmPasswordReset(token: String, newPassword: String): Result<MessageResponse> =
|
|
||||||
safeApiCall(gson) { api.confirmPasswordReset(ConfirmPasswordResetRequest(token, newPassword)) }
|
|
||||||
|
|
||||||
suspend fun deleteAccount(): Result<Unit> =
|
|
||||||
safeApiCall(gson) { api.deleteAccount() }
|
|
||||||
|
|
||||||
suspend fun restoreAccount(): Result<RestoreAccountResponse> =
|
|
||||||
safeApiCall(gson) { api.restoreAccount() }
|
|
||||||
|
|
||||||
fun logout() = tokenStore.clearToken()
|
|
||||||
fun isLoggedIn() = tokenStore.isLoggedIn()
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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.entity.PantryItemEntity
|
|
||||||
import com.pantree.app.data.model.AddPantryItemRequest
|
|
||||||
import com.pantree.app.data.model.PantryItemDto
|
|
||||||
import com.pantree.app.data.model.PantryItemResponse
|
|
||||||
import com.pantree.app.data.model.PantryListResponse
|
|
||||||
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 javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class PantryRepository @Inject constructor(
|
|
||||||
private val api: ApiService,
|
|
||||||
private val pantryDao: PantryDao,
|
|
||||||
private val gson: Gson
|
|
||||||
) {
|
|
||||||
fun getLocalItems(): Flow<List<PantryItemEntity>> = pantryDao.getAllItems()
|
|
||||||
|
|
||||||
suspend fun fetchAndCacheItems(): Result<PantryListResponse> {
|
|
||||||
val result = safeApiCall(gson) { api.getPantryItems() }
|
|
||||||
if (result is Result.Success) {
|
|
||||||
pantryDao.deleteAll()
|
|
||||||
pantryDao.insertItems(result.data.items.map { it.toEntity() })
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addItem(name: String, quantity: Int): Result<PantryItemResponse> {
|
|
||||||
val result = safeApiCall(gson) { api.addPantryItem(AddPantryItemRequest(name, quantity)) }
|
|
||||||
if (result is Result.Success) {
|
|
||||||
pantryDao.insertItem(result.data.item.toEntity())
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateItem(id: String, quantity: Int): Result<PantryItemResponse> {
|
|
||||||
val result = safeApiCall(gson) { api.updatePantryItem(id, UpdatePantryItemRequest(quantity)) }
|
|
||||||
if (result is Result.Success) {
|
|
||||||
pantryDao.insertItem(result.data.item.toEntity())
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun deleteItem(id: String): Result<Unit> {
|
|
||||||
val result = safeApiCall(gson) { api.deletePantryItem(id) }
|
|
||||||
if (result is Result.Success) {
|
|
||||||
pantryDao.deleteItemById(id)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PantryItemDto.toEntity() = PantryItemEntity(
|
|
||||||
id = id, itemName = itemName, quantity = quantity,
|
|
||||||
lastModified = lastModified, createdAt = createdAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package com.pantree.app.data.repository
|
|
||||||
|
|
||||||
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 javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class RecipeRepository @Inject constructor(
|
|
||||||
private val api: ApiService,
|
|
||||||
private val recipeDao: RecipeDao,
|
|
||||||
private val gson: Gson
|
|
||||||
) {
|
|
||||||
fun getLocalRecipes(): Flow<List<RecipeEntity>> = recipeDao.getAllRecipes()
|
|
||||||
fun getCanMakeRecipes(): Flow<List<RecipeEntity>> = recipeDao.getCanMakeRecipes()
|
|
||||||
fun getPartialRecipes(): Flow<List<RecipeEntity>> = recipeDao.getPartialRecipes()
|
|
||||||
|
|
||||||
suspend fun fetchRecipes(
|
|
||||||
filter: String = "all",
|
|
||||||
page: Int = 1,
|
|
||||||
search: String? = null
|
|
||||||
): Result<RecipeListResponse> {
|
|
||||||
val result = safeApiCall(gson) { api.getRecipes(filter, page, 20, search) }
|
|
||||||
if (result is Result.Success && page == 1) {
|
|
||||||
recipeDao.deleteAll()
|
|
||||||
recipeDao.insertRecipes(result.data.recipes.map { it.toEntity() })
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getRecipeById(id: String, scale: Int = 1): Result<RecipeDetailResponse> =
|
|
||||||
safeApiCall(gson) { api.getRecipeById(id, scale) }
|
|
||||||
|
|
||||||
private fun RecipeSummaryDto.toEntity() = RecipeEntity(
|
|
||||||
id = id, name = name, servings = servings,
|
|
||||||
ingredientCount = ingredientCount,
|
|
||||||
availableIngredientCount = availableIngredientCount,
|
|
||||||
canMake = canMake,
|
|
||||||
availabilityPercentage = availabilityPercentage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package com.pantree.app.data.repository
|
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.pantree.app.data.local.SyncPreferences
|
|
||||||
import com.pantree.app.data.local.dao.PantryDao
|
|
||||||
import com.pantree.app.data.local.dao.ShoppingListDao
|
|
||||||
import com.pantree.app.data.local.entity.PantryItemEntity
|
|
||||||
import com.pantree.app.data.local.entity.ShoppingListEntity
|
|
||||||
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
|
||||||
import com.pantree.app.data.model.SyncResponse
|
|
||||||
import com.pantree.app.data.remote.ApiService
|
|
||||||
import com.pantree.app.util.Result
|
|
||||||
import com.pantree.app.util.safeApiCall
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class SyncRepository @Inject constructor(
|
|
||||||
private val api: ApiService,
|
|
||||||
private val pantryDao: PantryDao,
|
|
||||||
private val shoppingListDao: ShoppingListDao,
|
|
||||||
private val syncPreferences: SyncPreferences,
|
|
||||||
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 ->
|
|
||||||
shoppingListDao.insertList(
|
|
||||||
ShoppingListEntity(
|
|
||||||
id = list.id, listName = list.listName,
|
|
||||||
itemCount = list.items.updated.size,
|
|
||||||
checkedCount = list.items.updated.count { it.checkedOff },
|
|
||||||
lastModified = list.lastModified, createdAt = list.lastModified
|
|
||||||
)
|
|
||||||
)
|
|
||||||
list.items.updated.forEach { item ->
|
|
||||||
shoppingListDao.insertItem(
|
|
||||||
ShoppingListItemEntity(
|
|
||||||
id = item.id, shoppingListId = list.id,
|
|
||||||
itemName = item.itemName, quantity = item.quantity,
|
|
||||||
unit = item.unit, checkedOff = item.checkedOff,
|
|
||||||
lastModified = item.lastModified
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
list.items.deleted.forEach { shoppingListDao.deleteItemById(it) }
|
|
||||||
}
|
|
||||||
data.shoppingLists.deleted.forEach { shoppingListDao.deleteListById(it) }
|
|
||||||
|
|
||||||
syncPreferences.updateLastSync(data.serverTimestamp)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package com.pantree.app.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
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.TokenStore
|
|
||||||
import com.pantree.app.data.local.dao.PantryDao
|
|
||||||
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 dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object AppModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideGson(): Gson = GsonBuilder().create()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
|
||||||
val logging = HttpLoggingInterceptor().apply {
|
|
||||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
|
|
||||||
else HttpLoggingInterceptor.Level.NONE
|
|
||||||
}
|
|
||||||
return OkHttpClient.Builder()
|
|
||||||
.addInterceptor(authInterceptor)
|
|
||||||
.addInterceptor(logging)
|
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit =
|
|
||||||
Retrofit.Builder()
|
|
||||||
.baseUrl(BuildConfig.BASE_URL)
|
|
||||||
.client(okHttpClient)
|
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideApiService(retrofit: Retrofit): ApiService =
|
|
||||||
retrofit.create(ApiService::class.java)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDatabase(@ApplicationContext context: Context): PantreeDatabase =
|
|
||||||
Room.databaseBuilder(context, PantreeDatabase::class.java, "pantree.db")
|
|
||||||
.fallbackToDestructiveMigration()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
@Provides fun providePantryDao(db: PantreeDatabase): PantryDao = db.pantryDao()
|
|
||||||
@Provides fun provideRecipeDao(db: PantreeDatabase): RecipeDao = db.recipeDao()
|
|
||||||
@Provides fun provideShoppingListDao(db: PantreeDatabase): ShoppingListDao = db.shoppingListDao()
|
|
||||||
}
|
|
||||||
@@ -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 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package com.pantree.app.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoadingState(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
message: String = "Loading..."
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ErrorState(
|
|
||||||
message: String,
|
|
||||||
onRetry: (() -> Unit)? = null,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier.fillMaxSize().padding(32.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "\uD83D\uDE15",
|
|
||||||
style = MaterialTheme.typography.headlineLarge
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
if (onRetry != null) {
|
|
||||||
Button(onClick = onRetry) {
|
|
||||||
Text("Try again")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EmptyState(
|
|
||||||
emoji: String,
|
|
||||||
title: String,
|
|
||||||
subtitle: String,
|
|
||||||
actionLabel: String? = null,
|
|
||||||
onAction: (() -> Unit)? = null,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier.fillMaxSize().padding(32.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Text(text = emoji, style = MaterialTheme.typography.headlineLarge)
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
if (actionLabel != null && onAction != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Button(onClick = onAction) {
|
|
||||||
Text(actionLabel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OfflineBanner(modifier: Modifier = Modifier) {
|
|
||||||
Surface(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
color = MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "\uD83D\uDCF5 You're offline. Showing cached data.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PantreeTopBar(
|
|
||||||
title: String,
|
|
||||||
onNavigateBack: (() -> Unit)? = null,
|
|
||||||
actions: @Composable RowScope.() -> Unit = {}
|
|
||||||
) {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(title, style = MaterialTheme.typography.titleLarge) },
|
|
||||||
navigationIcon = {
|
|
||||||
if (onNavigateBack != null) {
|
|
||||||
IconButton(onClick = onNavigateBack) {
|
|
||||||
Icon(
|
|
||||||
imageVector = androidx.compose.material.icons.Icons.Default.ArrowBack,
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = actions,
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
package com.pantree.app.ui.navigation
|
|
||||||
|
|
||||||
sealed class Screen(val route: String) {
|
|
||||||
// Auth
|
|
||||||
object Splash : Screen("splash")
|
|
||||||
object SignIn : Screen("signin")
|
|
||||||
object SignUp : Screen("signup")
|
|
||||||
object ForgotPassword : Screen("forgot_password")
|
|
||||||
object ResetPassword : Screen("reset_password/{token}") {
|
|
||||||
fun createRoute(token: String) = "reset_password/$token"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main
|
|
||||||
object Pantry : Screen("pantry")
|
|
||||||
object Recipes : Screen("recipes")
|
|
||||||
object RecipeDetail : Screen("recipe/{recipeId}") {
|
|
||||||
fun createRoute(id: String) = "recipe/$id"
|
|
||||||
}
|
|
||||||
object ShoppingLists : Screen("shopping_lists")
|
|
||||||
object ShoppingListDetail : Screen("shopping_list/{listId}") {
|
|
||||||
fun createRoute(id: String) = "shopping_list/$id"
|
|
||||||
}
|
|
||||||
object Settings : Screen("settings")
|
|
||||||
}
|
|
||||||
@@ -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") } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.pantree.app.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
// Brand palette
|
|
||||||
val PantreeGreen = Color(0xFF2D6A4F)
|
|
||||||
val PantreeGreenLight = Color(0xFF52B788)
|
|
||||||
val PantreeGreenDark = Color(0xFF1B4332)
|
|
||||||
val PantreeCream = Color(0xFFF8F4EF)
|
|
||||||
val PantreeOrange = Color(0xFFE76F51)
|
|
||||||
val PantreeOrangeLight = Color(0xFFF4A261)
|
|
||||||
|
|
||||||
// Semantic
|
|
||||||
val SuccessGreen = Color(0xFF40916C)
|
|
||||||
val ErrorRed = Color(0xFFD62828)
|
|
||||||
val WarningAmber = Color(0xFFF4A261)
|
|
||||||
val NeutralGray = Color(0xFF6B7280)
|
|
||||||
val SurfaceWhite = Color(0xFFFFFFFF)
|
|
||||||
val BackgroundCream = Color(0xFFF8F4EF)
|
|
||||||
|
|
||||||
// Dark theme
|
|
||||||
val PantreeGreenDarkTheme = Color(0xFF52B788)
|
|
||||||
val SurfaceDark = Color(0xFF1C1C1E)
|
|
||||||
val BackgroundDark = Color(0xFF121212)
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package com.pantree.app.ui.theme
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
|
||||||
primary = PantreeGreen,
|
|
||||||
onPrimary = SurfaceWhite,
|
|
||||||
primaryContainer = PantreeGreenLight,
|
|
||||||
onPrimaryContainer = PantreeGreenDark,
|
|
||||||
secondary = PantreeOrange,
|
|
||||||
onSecondary = SurfaceWhite,
|
|
||||||
secondaryContainer = PantreeOrangeLight,
|
|
||||||
background = BackgroundCream,
|
|
||||||
onBackground = PantreeGreenDark,
|
|
||||||
surface = SurfaceWhite,
|
|
||||||
onSurface = PantreeGreenDark,
|
|
||||||
error = ErrorRed,
|
|
||||||
onError = SurfaceWhite,
|
|
||||||
outline = NeutralGray
|
|
||||||
)
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
|
||||||
primary = PantreeGreenDarkTheme,
|
|
||||||
onPrimary = PantreeGreenDark,
|
|
||||||
primaryContainer = PantreeGreenDark,
|
|
||||||
onPrimaryContainer = PantreeGreenLight,
|
|
||||||
secondary = PantreeOrangeLight,
|
|
||||||
onSecondary = SurfaceDark,
|
|
||||||
background = BackgroundDark,
|
|
||||||
onBackground = PantreeCream,
|
|
||||||
surface = SurfaceDark,
|
|
||||||
onSurface = PantreeCream,
|
|
||||||
error = ErrorRed,
|
|
||||||
onError = SurfaceWhite
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PantreeTheme(
|
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
|
||||||
val view = LocalView.current
|
|
||||||
if (!view.isInEditMode) {
|
|
||||||
SideEffect {
|
|
||||||
val window = (view.context as Activity).window
|
|
||||||
window.statusBarColor = colorScheme.primary.toArgb()
|
|
||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = PantreeTypography,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package com.pantree.app.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
val PantreeTypography = Typography(
|
|
||||||
headlineLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 32.sp,
|
|
||||||
lineHeight = 40.sp
|
|
||||||
),
|
|
||||||
headlineMedium = TextStyle(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 24.sp,
|
|
||||||
lineHeight = 32.sp
|
|
||||||
),
|
|
||||||
headlineSmall = TextStyle(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 20.sp,
|
|
||||||
lineHeight = 28.sp
|
|
||||||
),
|
|
||||||
titleLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
lineHeight = 24.sp
|
|
||||||
),
|
|
||||||
titleMedium = TextStyle(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 22.sp
|
|
||||||
),
|
|
||||||
bodyLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp
|
|
||||||
),
|
|
||||||
bodyMedium = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
lineHeight = 20.sp
|
|
||||||
),
|
|
||||||
bodySmall = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
lineHeight = 16.sp
|
|
||||||
),
|
|
||||||
labelLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
lineHeight = 20.sp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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>
|
|
||||||
@@ -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'
|
|
||||||
@@ -9,18 +9,22 @@ function requireEnv(key: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTest = process.env.NODE_ENV === 'test';
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||||
databaseUrl: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test',
|
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',
|
jwtSecret: isTest
|
||||||
|
? (process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever')
|
||||||
|
: requireEnv('JWT_SECRET'),
|
||||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h',
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h',
|
||||||
googleClientId: process.env.GOOGLE_CLIENT_ID ?? '',
|
googleClientId: process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
sendgridApiKey: process.env.SENDGRID_API_KEY ?? '',
|
sendgridApiKey: process.env.SENDGRID_API_KEY ?? '',
|
||||||
sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app',
|
sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app',
|
||||||
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
|
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
|
||||||
passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password',
|
passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password',
|
||||||
isTest: process.env.NODE_ENV === 'test',
|
isTest,
|
||||||
isDev: process.env.NODE_ENV === 'development',
|
isDev: process.env.NODE_ENV === 'development',
|
||||||
isProd: process.env.NODE_ENV === 'production',
|
isProd: process.env.NODE_ENV === 'production',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ export async function up(knex: Knex): Promise<void> {
|
|||||||
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||||
table.string('token_hash', 255).notNullable();
|
table.string('token_hash', 255).notNullable();
|
||||||
|
table.string('lookup_hash', 64).notNullable();
|
||||||
table.timestamp('expires_at', { useTz: true }).notNullable();
|
table.timestamp('expires_at', { useTz: true }).notNullable();
|
||||||
table.timestamp('used_at', { useTz: true }).nullable();
|
table.timestamp('used_at', { useTz: true }).nullable();
|
||||||
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
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)`);
|
await knex.raw(`CREATE INDEX idx_prt_user_id ON password_reset_tokens (user_id)`);
|
||||||
|
await knex.raw(`CREATE UNIQUE INDEX idx_prt_lookup_hash ON password_reset_tokens (lookup_hash)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function down(knex: Knex): Promise<void> {
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
|||||||
@@ -28,10 +28,9 @@ export async function authMiddleware(
|
|||||||
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
|
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user still exists and is not hard-deleted
|
// Verify user still exists (including soft-deleted — they may be hitting /restore-account)
|
||||||
const user = await db('users')
|
const user = await db('users')
|
||||||
.where({ id: payload.userId })
|
.where({ id: payload.userId })
|
||||||
.whereNull('deletion_scheduled_at')
|
|
||||||
.select('id', 'deleted_at', 'deletion_scheduled_at')
|
.select('id', 'deleted_at', 'deletion_scheduled_at')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -39,10 +38,16 @@ export async function authMiddleware(
|
|||||||
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
|
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block access for soft-deleted accounts (except restore endpoint)
|
// Soft-deleted accounts may only access the restore-account route
|
||||||
if (user.deleted_at && !req.path.includes('/restore-account')) {
|
if (user.deleted_at) {
|
||||||
|
const isRestorePath =
|
||||||
|
req.path === '/auth/restore-account' ||
|
||||||
|
req.path.endsWith('/restore-account');
|
||||||
|
|
||||||
|
if (!isRestorePath) {
|
||||||
return next(createError('Account is pending deletion.', 403, 'FORBIDDEN'));
|
return next(createError('Account is pending deletion.', 403, 'FORBIDDEN'));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req.userId = payload.userId;
|
req.userId = payload.userId;
|
||||||
next();
|
next();
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ function formatUser(user: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a fast, constant-length lookup key from a raw reset token.
|
||||||
|
* HMAC-SHA256 with the JWT secret as the key — not a secret in itself,
|
||||||
|
* but prevents offline dictionary attacks against the stored hashes.
|
||||||
|
*/
|
||||||
|
function hmacToken(rawToken: string): string {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', config.jwtSecret)
|
||||||
|
.update(rawToken)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
async signup(email: string, password: string, name: string) {
|
async signup(email: string, password: string, name: string) {
|
||||||
const existing = await db('users')
|
const existing = await db('users')
|
||||||
@@ -166,6 +178,7 @@ export const authService = {
|
|||||||
|
|
||||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||||
const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS);
|
const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS);
|
||||||
|
const lookup_hash = hmacToken(rawToken);
|
||||||
const expires_at = new Date(
|
const expires_at = new Date(
|
||||||
Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000
|
Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000
|
||||||
).toISOString();
|
).toISOString();
|
||||||
@@ -173,6 +186,7 @@ export const authService = {
|
|||||||
await db('password_reset_tokens').insert({
|
await db('password_reset_tokens').insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash,
|
token_hash,
|
||||||
|
lookup_hash,
|
||||||
expires_at,
|
expires_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,22 +194,22 @@ export const authService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async confirmPasswordReset(rawToken: string, newPassword: string) {
|
async confirmPasswordReset(rawToken: string, newPassword: string) {
|
||||||
// Find all unexpired, unused tokens and check each
|
// Derive the lookup key and fetch the single matching row — no full-table scan
|
||||||
const tokens = await db('password_reset_tokens')
|
const lookup_hash = hmacToken(rawToken);
|
||||||
|
|
||||||
|
const candidate = await db('password_reset_tokens')
|
||||||
|
.where({ lookup_hash })
|
||||||
.whereNull('used_at')
|
.whereNull('used_at')
|
||||||
.where('expires_at', '>', db.fn.now())
|
.where('expires_at', '>', db.fn.now())
|
||||||
.orderBy('created_at', 'desc');
|
.first();
|
||||||
|
|
||||||
let matchedToken = null;
|
if (!candidate) {
|
||||||
for (const t of tokens) {
|
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
|
||||||
const match = await bcrypt.compare(rawToken, t.token_hash);
|
|
||||||
if (match) {
|
|
||||||
matchedToken = t;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matchedToken) {
|
// bcrypt-verify the raw token against the stored hash
|
||||||
|
const valid = await bcrypt.compare(rawToken, candidate.token_hash);
|
||||||
|
if (!valid) {
|
||||||
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
|
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,11 +217,11 @@ export const authService = {
|
|||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx('users')
|
await trx('users')
|
||||||
.where({ id: matchedToken.user_id })
|
.where({ id: candidate.user_id })
|
||||||
.update({ password_hash, updated_at: trx.fn.now() });
|
.update({ password_hash, updated_at: trx.fn.now() });
|
||||||
|
|
||||||
await trx('password_reset_tokens')
|
await trx('password_reset_tokens')
|
||||||
.where({ id: matchedToken.id })
|
.where({ id: candidate.id })
|
||||||
.update({ used_at: trx.fn.now() });
|
.update({ used_at: trx.fn.now() });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user