1 Commits

Author SHA1 Message Date
Azriel
75db2ea8f5 feat(android): implement full Android frontend
- Build config: build.gradle (app + root), settings.gradle, AndroidManifest
- Application entry: PantreeApplication (Hilt), MainActivity (Compose host)
- Data layer:
  - ApiService (Retrofit interface, all 20 endpoints)
  - ApiModels (all request/response DTOs)
  - AuthInterceptor (JWT Bearer header injection)
  - TokenStore (EncryptedSharedPreferences)
  - SyncPreferences (DataStore, last-sync timestamp)
  - PantreeDatabase (Room, 5 entities)
  - Entities: PantryItemEntity, RecipeEntity, RecipeIngredientEntity, ShoppingListEntity, ShoppingListItemEntity
  - DAOs: PantryDao, RecipeDao, ShoppingListDao
  - Repositories: AuthRepository, PantryRepository, RecipeRepository, ShoppingListRepository, SyncRepository
- DI: AppModule (Hilt, provides OkHttp/Retrofit/Room/DAOs)
- Util: Result<T> sealed class, safeApiCall, toUserMessage (human-readable error mapping)
- Sync: SyncManager (DefaultLifecycleObserver, triggers on app open/foreground)
- Theme: Color, Type, Theme (Material 3, light + dark)
- Navigation: Screen sealed class, PantreeNavGraph (deep links for password reset)
- Shared components: LoadingState, ErrorState, EmptyState, OfflineBanner, PantreeTopBar
- Auth screens: SplashScreen, SignInScreen, SignUpScreen, ForgotPasswordScreen, ResetPasswordScreen + AuthViewModel
- Pantry screen: PantryScreen (list/add/edit/delete dialogs) + PantryViewModel
- Recipe screens: RecipesScreen (filter chips, search, availability badges) + RecipeDetailScreen (scale 1x/2x/3x, ingredient pantry status) + RecipeViewModel
- Shopping screens: ShoppingListsScreen + ShoppingListDetailScreen (check-off, progress bar, merge feedback, add-from-recipes) + ShoppingListViewModel
- Settings screen: sign out + account deletion (15-day window copy) + SettingsViewModel
- Tests: ResultTest (unit), RecipeDetailScreenTest (unit), PantryScreenTest (instrumented)
- README: architecture, module structure, UI states table, sync strategy, setup

Every screen handles loading / error / empty / success states.
Offline: cached Room data shown read-only.
2026-05-10 15:09:12 +00:00
55 changed files with 4158 additions and 45 deletions

76
android/README.md Normal file
View File

@@ -0,0 +1,76 @@
# 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
```

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

@@ -0,0 +1,128 @@
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'
}

View File

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

View File

@@ -0,0 +1,39 @@
<?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>

View File

@@ -0,0 +1,27 @@
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()
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,29 @@
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
}

View File

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

View File

@@ -0,0 +1,50 @@
package com.pantree.app.data.local
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"pantree_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String, expiresAt: String) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_EXPIRES_AT, expiresAt)
.apply()
}
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
fun getExpiresAt(): String? = prefs.getString(KEY_EXPIRES_AT, null)
fun clearToken() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_EXPIRES_AT)
.apply()
}
fun isLoggedIn(): Boolean = getToken() != null
companion object {
private const val KEY_TOKEN = "jwt_token"
private const val KEY_EXPIRES_AT = "jwt_expires_at"
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
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
)

View File

@@ -0,0 +1,251 @@
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
)

View File

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

View File

@@ -0,0 +1,22 @@
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)
}
}

View File

@@ -0,0 +1,64 @@
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()
}

View File

@@ -0,0 +1,63 @@
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
)
}

View File

@@ -0,0 +1,50 @@
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
)
}

View File

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

View File

@@ -0,0 +1,65 @@
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
}
}

View File

@@ -0,0 +1,75 @@
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()
}

View File

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

View File

@@ -0,0 +1,147 @@
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
)
)
}

View File

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

View File

@@ -0,0 +1,24 @@
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")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
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)

View File

@@ -0,0 +1,65 @@
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
)
}

View File

@@ -0,0 +1,54 @@
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
)
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
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())
}
}

8
android/build.gradle Normal file
View File

@@ -0,0 +1,8 @@
// 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
}

16
android/settings.gradle Normal file
View File

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

View File

@@ -9,22 +9,18 @@ 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: isTest jwtSecret: process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever',
? (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, isTest: process.env.NODE_ENV === 'test',
isDev: process.env.NODE_ENV === 'development', isDev: process.env.NODE_ENV === 'development',
isProd: process.env.NODE_ENV === 'production', isProd: process.env.NODE_ENV === 'production',
}; };

View File

@@ -5,14 +5,12 @@ 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> {

View File

@@ -28,9 +28,10 @@ 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 (including soft-deleted — they may be hitting /restore-account) // Verify user still exists and is not hard-deleted
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();
@@ -38,16 +39,10 @@ export async function authMiddleware(
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED')); return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
} }
// Soft-deleted accounts may only access the restore-account route // Block access for soft-deleted accounts (except restore endpoint)
if (user.deleted_at) { if (user.deleted_at && !req.path.includes('/restore-account')) {
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();

View File

@@ -21,18 +21,6 @@ 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')
@@ -178,7 +166,6 @@ 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();
@@ -186,7 +173,6 @@ 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,
}); });
@@ -194,22 +180,22 @@ export const authService = {
}, },
async confirmPasswordReset(rawToken: string, newPassword: string) { async confirmPasswordReset(rawToken: string, newPassword: string) {
// Derive the lookup key and fetch the single matching row — no full-table scan // Find all unexpired, unused tokens and check each
const lookup_hash = hmacToken(rawToken); const tokens = await db('password_reset_tokens')
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())
.first(); .orderBy('created_at', 'desc');
if (!candidate) { let matchedToken = null;
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN'); for (const t of tokens) {
const match = await bcrypt.compare(rawToken, t.token_hash);
if (match) {
matchedToken = t;
break;
}
} }
// bcrypt-verify the raw token against the stored hash if (!matchedToken) {
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');
} }
@@ -217,11 +203,11 @@ export const authService = {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx('users') await trx('users')
.where({ id: candidate.user_id }) .where({ id: matchedToken.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: candidate.id }) .where({ id: matchedToken.id })
.update({ used_at: trx.fn.now() }); .update({ used_at: trx.fn.now() });
}); });
}, },