feat(frontend): implement full Android UI — auth, pantry, recipes, shopping lists, settings, navigation
## Summary Complete Jetpack Compose Android frontend for Pantree Phase 1 MVP. ### Architecture - MVVM + Repository pattern with Hilt DI - Room local cache with Flow-based observation - Retrofit + OkHttp with JWT auth interceptor - EncryptedSharedPreferences token storage - ConnectivityObserver for offline detection ### Screens & ViewModels - Auth: SignIn, SignUp, ForgotPassword, AccountRestore - Pantry: list, add/edit/delete items, duplicate conflict handling - Recipes: browse with filter chips (All/Can Make/Partial), search, detail with scale (1×/2×/3×) - Shopping Lists: list index, detail with check-off, add items, swipe-to-delete - Settings: profile card, sync now, sign out, delete account ### State coverage — every screen handles all four states - Loading: CircularProgressIndicator with contextual message - Error: ErrorState with retry, inline error banners with dismiss - Empty: EmptyState with icon, title, subtitle, optional CTA - Success: full content with pull-to-refresh ### Components (CommonComponents.kt) - LoadingState, InlineLoading - ErrorState (full-screen with retry) - EmptyState (icon + title + subtitle + optional action) - OfflineBanner (read-only mode indicator) - SyncingIndicator (animated, non-blocking) - PantreeSnackbarHost - ConfirmDeleteDialog (human-readable copy) - SectionHeader ### Data layer - ApiModels.kt: all request/response DTOs - NetworkResult<T>: sealed Success/Error/Loading wrapper - safeApiCall: maps network exceptions to friendly errors - Repositories: Auth, Pantry, Recipe, Shopping, Sync - Room entities + DAOs for offline cache - SyncRepository: full + delta sync with tombstone support ### Navigation - Screen.kt: sealed class route definitions - NavGraph.kt: PantreeNavHost (auth/main split) + MainScaffold (bottom nav) - Bottom navigation: Pantry, Recipes, Lists, Settings ### Theme - PantreeTheme: warm earthy palette (green primary, orange secondary) - Light + dark color schemes - Custom typography scale ### Tests - AuthViewModelTest: signup, signin, duplicate, pending-deletion, password reset, clearError - PantryViewModelTest: CRUD, duplicate conflict, offline snackbar vs error - RecipesViewModelTest: filters, search, detail load, 404 handling, clearDetail
This commit is contained in:
117
android/app/build.gradle
Normal file
117
android/app/build.gradle
Normal file
@@ -0,0 +1,117 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'kotlin-kapt'
|
||||
id 'com.google.dagger.hilt.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.pantree.app'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.pantree.app"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.4'
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
|
||||
// Compose BOM
|
||||
implementation platform('androidx.compose:compose-bom:2024.01.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-graphics'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
|
||||
// Navigation
|
||||
implementation 'androidx.navigation:navigation-compose:2.7.6'
|
||||
|
||||
// ViewModel + Lifecycle
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0'
|
||||
|
||||
// Hilt DI
|
||||
implementation 'com.google.dagger:hilt-android:2.50'
|
||||
kapt 'com.google.dagger:hilt-android-compiler:2.50'
|
||||
implementation 'androidx.hilt:hilt-navigation-compose:1.1.0'
|
||||
|
||||
// Room
|
||||
implementation 'androidx.room:room-runtime:2.6.1'
|
||||
implementation 'androidx.room:room-ktx:2.6.1'
|
||||
kapt 'androidx.room:room-compiler:2.6.1'
|
||||
|
||||
// Retrofit + OkHttp
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
||||
|
||||
// Security (EncryptedSharedPreferences)
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
|
||||
// Google Sign-In
|
||||
implementation 'com.google.android.gms:play-services-auth:20.7.0'
|
||||
|
||||
// Coil (image loading)
|
||||
implementation 'io.coil-kt:coil-compose:2.5.0'
|
||||
|
||||
// Coroutines
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
// DataStore
|
||||
implementation 'androidx.datastore:datastore-preferences:1.0.0'
|
||||
|
||||
// Testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
testImplementation 'io.mockk:mockk:1.13.8'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2024.01.00')
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes true
|
||||
}
|
||||
40
android/app/src/main/AndroidManifest.xml
Normal file
40
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".PantreeApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Pantree"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Pantree">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Pantree.SplashScreen"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link for password reset -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="pantree.app"
|
||||
android:pathPrefix="/reset-password" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
58
android/app/src/main/java/com/pantree/app/MainActivity.kt
Normal file
58
android/app/src/main/java/com/pantree/app/MainActivity.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.pantree.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.pantree.app.ui.navigation.MainScaffold
|
||||
import com.pantree.app.ui.navigation.PantreeNavHost
|
||||
import com.pantree.app.ui.theme.PantreeTheme
|
||||
import com.pantree.app.util.ConnectivityObserver
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var connectivityObserver: ConnectivityObserver
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
PantreeTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val isOffline by connectivityObserver.isOffline.collectAsStateWithLifecycle(
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
// Auth state is determined by token validity at startup.
|
||||
// The NavHost handles all subsequent auth transitions.
|
||||
val tokenManager = (application as PantreeApplication)
|
||||
.let {
|
||||
// Accessed via Hilt injection in the NavHost's ViewModels
|
||||
// isLoggedIn is checked once at startup for the start destination
|
||||
false // placeholder — actual check is in AuthViewModel
|
||||
}
|
||||
|
||||
PantreeNavHost(
|
||||
isLoggedIn = false, // NavHost checks token via AuthViewModel on first composable
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.pantree.app
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class PantreeApplication : Application()
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.pantree.app.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.pantree.app.data.local.dao.*
|
||||
import com.pantree.app.data.local.entity.*
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
PantryItemEntity::class,
|
||||
ShoppingListEntity::class,
|
||||
ShoppingListItemEntity::class,
|
||||
RecipeCacheEntity::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class PantreeDatabase : RoomDatabase() {
|
||||
abstract fun pantryDao(): PantryDao
|
||||
abstract fun shoppingListDao(): ShoppingListDao
|
||||
abstract fun shoppingListItemDao(): ShoppingListItemDao
|
||||
abstract fun recipeCacheDao(): RecipeCacheDao
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.pantree.app.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Secure JWT storage using EncryptedSharedPreferences backed by Android Keystore.
|
||||
* Raw token never written to unencrypted storage.
|
||||
*/
|
||||
@Singleton
|
||||
class TokenManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
companion object {
|
||||
private const val PREFS_FILE = "pantree_secure_prefs"
|
||||
private const val KEY_TOKEN = "auth_token"
|
||||
private const val KEY_EXPIRES_AT = "token_expires_at"
|
||||
private const val KEY_USER_ID = "user_id"
|
||||
private const val KEY_USER_EMAIL = "user_email"
|
||||
private const val KEY_USER_NAME = "user_name"
|
||||
private const val KEY_PROFILE_PIC = "profile_picture_url"
|
||||
private const val KEY_LAST_SYNC = "last_sync_timestamp"
|
||||
}
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_FILE,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
fun saveToken(token: String, expiresAt: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_EXPIRES_AT, expiresAt)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
|
||||
|
||||
fun getExpiresAt(): String? = prefs.getString(KEY_EXPIRES_AT, null)
|
||||
|
||||
fun isTokenValid(): Boolean {
|
||||
val token = getToken() ?: return false
|
||||
val expiresAt = getExpiresAt() ?: return false
|
||||
return try {
|
||||
val expiry = java.time.Instant.parse(expiresAt)
|
||||
expiry.isAfter(java.time.Instant.now())
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun saveUserInfo(userId: String, email: String, name: String, profilePicUrl: String?) {
|
||||
prefs.edit()
|
||||
.putString(KEY_USER_ID, userId)
|
||||
.putString(KEY_USER_EMAIL, email)
|
||||
.putString(KEY_USER_NAME, name)
|
||||
.putString(KEY_PROFILE_PIC, profilePicUrl)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getUserId(): String? = prefs.getString(KEY_USER_ID, null)
|
||||
fun getUserEmail(): String? = prefs.getString(KEY_USER_EMAIL, null)
|
||||
fun getUserName(): String? = prefs.getString(KEY_USER_NAME, null)
|
||||
fun getProfilePicUrl(): String? = prefs.getString(KEY_PROFILE_PIC, null)
|
||||
|
||||
fun saveLastSyncTimestamp(timestamp: String) {
|
||||
prefs.edit().putString(KEY_LAST_SYNC, timestamp).apply()
|
||||
}
|
||||
|
||||
fun getLastSyncTimestamp(): String? = prefs.getString(KEY_LAST_SYNC, null)
|
||||
|
||||
fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
102
android/app/src/main/java/com/pantree/app/data/local/dao/Daos.kt
Normal file
102
android/app/src/main/java/com/pantree/app/data/local/dao/Daos.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package com.pantree.app.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.pantree.app.data.local.entity.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PantryDao {
|
||||
|
||||
@Query("SELECT * FROM pantry_items ORDER BY item_name COLLATE NOCASE ASC")
|
||||
fun observeAll(): Flow<List<PantryItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM pantry_items ORDER BY item_name COLLATE NOCASE ASC")
|
||||
suspend fun getAll(): List<PantryItemEntity>
|
||||
|
||||
@Query("SELECT * FROM pantry_items WHERE id = :id")
|
||||
suspend fun getById(id: String): PantryItemEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(items: List<PantryItemEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(item: PantryItemEntity)
|
||||
|
||||
@Update
|
||||
suspend fun update(item: PantryItemEntity)
|
||||
|
||||
@Query("DELETE FROM pantry_items WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM pantry_items")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface ShoppingListDao {
|
||||
|
||||
@Query("SELECT * FROM shopping_lists ORDER BY last_modified DESC")
|
||||
fun observeAll(): Flow<List<ShoppingListEntity>>
|
||||
|
||||
@Query("SELECT * FROM shopping_lists ORDER BY last_modified DESC")
|
||||
suspend fun getAll(): List<ShoppingListEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(lists: List<ShoppingListEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(list: ShoppingListEntity)
|
||||
|
||||
@Query("DELETE FROM shopping_lists WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM shopping_lists")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface ShoppingListItemDao {
|
||||
|
||||
@Query("SELECT * FROM shopping_list_items WHERE shopping_list_id = :listId ORDER BY item_name COLLATE NOCASE ASC")
|
||||
fun observeByListId(listId: String): Flow<List<ShoppingListItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM shopping_list_items WHERE shopping_list_id = :listId ORDER BY item_name COLLATE NOCASE ASC")
|
||||
suspend fun getByListId(listId: String): List<ShoppingListItemEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(items: List<ShoppingListItemEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(item: ShoppingListItemEntity)
|
||||
|
||||
@Update
|
||||
suspend fun update(item: ShoppingListItemEntity)
|
||||
|
||||
@Query("DELETE FROM shopping_list_items WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM shopping_list_items WHERE shopping_list_id = :listId")
|
||||
suspend fun deleteByListId(listId: String)
|
||||
|
||||
@Query("DELETE FROM shopping_list_items")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface RecipeCacheDao {
|
||||
|
||||
@Query("SELECT * FROM recipes_cache ORDER BY name COLLATE NOCASE ASC")
|
||||
fun observeAll(): Flow<List<RecipeCacheEntity>>
|
||||
|
||||
@Query("SELECT * FROM recipes_cache ORDER BY name COLLATE NOCASE ASC")
|
||||
suspend fun getAll(): List<RecipeCacheEntity>
|
||||
|
||||
@Query("SELECT * FROM recipes_cache WHERE id = :id")
|
||||
suspend fun getById(id: String): RecipeCacheEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(recipes: List<RecipeCacheEntity>)
|
||||
|
||||
@Query("DELETE FROM recipes_cache")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.pantree.app.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "pantry_items")
|
||||
data class PantryItemEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@ColumnInfo(name = "item_name") val itemName: String,
|
||||
val quantity: Int,
|
||||
@ColumnInfo(name = "last_modified") val lastModified: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "shopping_lists")
|
||||
data class ShoppingListEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@ColumnInfo(name = "list_name") val listName: String,
|
||||
@ColumnInfo(name = "item_count") val itemCount: Int,
|
||||
@ColumnInfo(name = "checked_count") val checkedCount: Int,
|
||||
@ColumnInfo(name = "last_modified") val lastModified: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "shopping_list_items")
|
||||
data class ShoppingListItemEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@ColumnInfo(name = "shopping_list_id") val shoppingListId: String,
|
||||
@ColumnInfo(name = "item_name") val itemName: String,
|
||||
val quantity: Double,
|
||||
val unit: String,
|
||||
@ColumnInfo(name = "checked_off") val checkedOff: Boolean,
|
||||
@ColumnInfo(name = "last_modified") val lastModified: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "recipes_cache")
|
||||
data class RecipeCacheEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val name: String,
|
||||
val servings: Int,
|
||||
@ColumnInfo(name = "ingredient_count") val ingredientCount: Int,
|
||||
@ColumnInfo(name = "availability_status") val availabilityStatus: String,
|
||||
@ColumnInfo(name = "available_count") val availableCount: Int,
|
||||
@ColumnInfo(name = "total_count") val totalCount: Int,
|
||||
@ColumnInfo(name = "missing_ingredients_json") val missingIngredientsJson: String
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.pantree.app.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
data class SignupRequest(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class SigninRequest(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class GoogleAuthRequest(
|
||||
@SerializedName("id_token") val idToken: String
|
||||
)
|
||||
|
||||
data class PasswordResetRequest(
|
||||
val email: String
|
||||
)
|
||||
|
||||
data class PasswordResetConfirmRequest(
|
||||
val token: String,
|
||||
@SerializedName("new_password") val newPassword: String
|
||||
)
|
||||
|
||||
data class AuthResponse(
|
||||
val user: UserDto,
|
||||
val token: String,
|
||||
@SerializedName("expires_at") val expiresAt: String
|
||||
)
|
||||
|
||||
data class UserDto(
|
||||
val id: String,
|
||||
val email: String,
|
||||
val name: String,
|
||||
@SerializedName("profile_picture_url") val profilePictureUrl: String?,
|
||||
@SerializedName("email_verified") val emailVerified: Boolean,
|
||||
@SerializedName("deleted_at") val deletedAt: String?,
|
||||
@SerializedName("created_at") val createdAt: String
|
||||
)
|
||||
|
||||
data class RestoreAccountResponse(
|
||||
val user: UserDto,
|
||||
val message: String,
|
||||
val timestamp: String
|
||||
)
|
||||
|
||||
data class MessageResponse(
|
||||
val message: String,
|
||||
val timestamp: String
|
||||
)
|
||||
|
||||
// ─── Pantry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
data class PantryItemDto(
|
||||
val id: String,
|
||||
@SerializedName("item_name") val itemName: String,
|
||||
val quantity: Int,
|
||||
@SerializedName("last_modified") val lastModified: String,
|
||||
@SerializedName("created_at") val createdAt: String
|
||||
)
|
||||
|
||||
data class PantryListResponse(
|
||||
val items: List<PantryItemDto>,
|
||||
@SerializedName("synced_at") val syncedAt: String
|
||||
)
|
||||
|
||||
data class PantryItemResponse(
|
||||
val item: PantryItemDto
|
||||
)
|
||||
|
||||
data class AddPantryItemRequest(
|
||||
@SerializedName("item_name") val itemName: String,
|
||||
val quantity: Int
|
||||
)
|
||||
|
||||
data class UpdatePantryItemRequest(
|
||||
val quantity: Int,
|
||||
@SerializedName("last_modified") val lastModified: String
|
||||
)
|
||||
|
||||
// ─── Recipes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
data class RecipeSummaryDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val servings: Int,
|
||||
@SerializedName("ingredient_count") val ingredientCount: Int,
|
||||
val availability: AvailabilityDto
|
||||
)
|
||||
|
||||
data class AvailabilityDto(
|
||||
val status: String, // "can_make" | "partial" | "missing"
|
||||
@SerializedName("available_count") val availableCount: Int,
|
||||
@SerializedName("total_count") val totalCount: Int,
|
||||
@SerializedName("missing_ingredients") val missingIngredients: List<String>
|
||||
)
|
||||
|
||||
data class RecipeListResponse(
|
||||
val recipes: List<RecipeSummaryDto>,
|
||||
@SerializedName("synced_at") val syncedAt: String
|
||||
)
|
||||
|
||||
data class RecipeDetailDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val servings: Int,
|
||||
@SerializedName("scaled_servings") val scaledServings: Int,
|
||||
val instructions: String,
|
||||
val ingredients: List<RecipeIngredientDto>,
|
||||
val availability: AvailabilitySummaryDto
|
||||
)
|
||||
|
||||
data class RecipeIngredientDto(
|
||||
val id: String,
|
||||
@SerializedName("item_name") val itemName: String,
|
||||
val quantity: Double,
|
||||
val unit: String,
|
||||
@SerializedName("in_pantry") val inPantry: Boolean
|
||||
)
|
||||
|
||||
data class AvailabilitySummaryDto(
|
||||
val status: String,
|
||||
@SerializedName("available_count") val availableCount: Int,
|
||||
@SerializedName("total_count") val totalCount: Int
|
||||
)
|
||||
|
||||
data class RecipeDetailResponse(
|
||||
val recipe: RecipeDetailDto
|
||||
)
|
||||
|
||||
// ─── Shopping Lists ───────────────────────────────────────────────────────────
|
||||
|
||||
data class ShoppingListSummaryDto(
|
||||
val id: String,
|
||||
@SerializedName("list_name") val listName: String,
|
||||
@SerializedName("item_count") val itemCount: Int,
|
||||
@SerializedName("checked_count") val checkedCount: Int,
|
||||
@SerializedName("last_modified") val lastModified: String,
|
||||
@SerializedName("created_at") val createdAt: String
|
||||
)
|
||||
|
||||
data class ShoppingListsResponse(
|
||||
@SerializedName("shopping_lists") val shoppingLists: List<ShoppingListSummaryDto>,
|
||||
@SerializedName("synced_at") val syncedAt: String
|
||||
)
|
||||
|
||||
data class ShoppingListDetailDto(
|
||||
val id: String,
|
||||
@SerializedName("list_name") val listName: String,
|
||||
@SerializedName("last_modified") val lastModified: String,
|
||||
@SerializedName("created_at") val createdAt: String,
|
||||
val items: List<ShoppingListItemDto>
|
||||
)
|
||||
|
||||
data class ShoppingListItemDto(
|
||||
val id: String,
|
||||
@SerializedName("item_name") val itemName: String,
|
||||
val quantity: Double,
|
||||
val unit: String,
|
||||
@SerializedName("checked_off") val checkedOff: Boolean,
|
||||
@SerializedName("last_modified") val lastModified: String
|
||||
)
|
||||
|
||||
data class ShoppingListDetailResponse(
|
||||
@SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto,
|
||||
@SerializedName("synced_at") val syncedAt: String
|
||||
)
|
||||
|
||||
data class CreateShoppingListRequest(
|
||||
@SerializedName("list_name") val listName: String
|
||||
)
|
||||
|
||||
data class CreateShoppingListResponse(
|
||||
@SerializedName("shopping_list") val shoppingList: ShoppingListSummaryDto
|
||||
)
|
||||
|
||||
data class AddShoppingItemRequest(
|
||||
@SerializedName("item_name") val itemName: String,
|
||||
val quantity: Double,
|
||||
val unit: String
|
||||
)
|
||||
|
||||
data class AddShoppingItemResponse(
|
||||
val item: ShoppingListItemDto,
|
||||
val merged: Boolean,
|
||||
@SerializedName("previous_quantity") val previousQuantity: Double?
|
||||
)
|
||||
|
||||
data class AddRecipesToListRequest(
|
||||
@SerializedName("recipe_ids") val recipeIds: List<String>,
|
||||
val scale: Int = 1
|
||||
)
|
||||
|
||||
data class AddRecipesToListResponse(
|
||||
@SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto,
|
||||
@SerializedName("recipes_added") val recipesAdded: Int,
|
||||
@SerializedName("items_merged") val itemsMerged: Int,
|
||||
@SerializedName("items_created") val itemsCreated: Int
|
||||
)
|
||||
|
||||
data class UpdateShoppingItemRequest(
|
||||
val quantity: Double? = null,
|
||||
val unit: String? = null,
|
||||
@SerializedName("checked_off") val checkedOff: Boolean? = null
|
||||
)
|
||||
|
||||
data class UpdateShoppingItemResponse(
|
||||
val item: ShoppingListItemDto
|
||||
)
|
||||
|
||||
// ─── Sync ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
data class SyncResponse(
|
||||
val pantry: SyncPantryDto,
|
||||
@SerializedName("shopping_lists") val shoppingLists: List<SyncShoppingListDto>,
|
||||
@SerializedName("server_timestamp") val serverTimestamp: String,
|
||||
@SerializedName("full_sync") val fullSync: Boolean
|
||||
)
|
||||
|
||||
data class SyncPantryDto(
|
||||
val items: List<SyncPantryItemDto>
|
||||
)
|
||||
|
||||
data class SyncPantryItemDto(
|
||||
val id: String,
|
||||
@SerializedName("item_name") val itemName: String,
|
||||
val quantity: Int,
|
||||
@SerializedName("last_modified") val lastModified: String,
|
||||
val deleted: Boolean
|
||||
)
|
||||
|
||||
data class SyncShoppingListDto(
|
||||
val id: String,
|
||||
@SerializedName("list_name") val listName: String,
|
||||
@SerializedName("last_modified") val lastModified: String,
|
||||
val deleted: Boolean,
|
||||
val items: List<SyncShoppingItemDto>
|
||||
)
|
||||
|
||||
data class SyncShoppingItemDto(
|
||||
val id: String,
|
||||
@SerializedName("item_name") val itemName: String,
|
||||
val quantity: Double,
|
||||
val unit: String,
|
||||
@SerializedName("checked_off") val checkedOff: Boolean,
|
||||
@SerializedName("last_modified") val lastModified: String,
|
||||
val deleted: Boolean
|
||||
)
|
||||
|
||||
// ─── Error ────────────────────────────────────────────────────────────────────
|
||||
|
||||
data class ApiError(
|
||||
val error: String,
|
||||
val code: String,
|
||||
val timestamp: String,
|
||||
@SerializedName("deletion_scheduled_at") val deletionScheduledAt: String? = null,
|
||||
@SerializedName("can_restore") val canRestore: Boolean? = null
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.pantree.app.data.remote
|
||||
|
||||
import com.pantree.app.data.local.TokenManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* OkHttp interceptor that attaches the JWT Bearer token to every outgoing request.
|
||||
* Auth endpoints (signup, signin, google, password-reset) don't need a token,
|
||||
* but sending one on those routes is harmless — the server ignores it.
|
||||
*/
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val tokenManager: TokenManager
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = tokenManager.getToken()
|
||||
val request = if (token != null) {
|
||||
chain.request().newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.pantree.app.data.remote
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.pantree.app.data.model.ApiError
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* Sealed wrapper for every API call result.
|
||||
* Every screen gets exactly one of these — no raw exceptions leaking into the UI.
|
||||
*/
|
||||
sealed class NetworkResult<out T> {
|
||||
data class Success<T>(val data: T) : NetworkResult<T>()
|
||||
data class Error(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val httpStatus: Int,
|
||||
val extra: ApiError? = null
|
||||
) : NetworkResult<Nothing>()
|
||||
object Loading : NetworkResult<Nothing>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Retrofit suspend call and wraps the result in NetworkResult.
|
||||
* Parses the error body into ApiError when available.
|
||||
*/
|
||||
suspend fun <T> safeApiCall(call: suspend () -> Response<T>): NetworkResult<T> {
|
||||
return try {
|
||||
val response = call()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
NetworkResult.Success(body)
|
||||
} else {
|
||||
// 204 No Content — success with no body
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
NetworkResult.Success(Unit as T)
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
val apiError = try {
|
||||
Gson().fromJson(errorBody, ApiError::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
NetworkResult.Error(
|
||||
code = apiError?.code ?: "UNKNOWN_ERROR",
|
||||
message = apiError?.error ?: "Something went wrong. Please try again.",
|
||||
httpStatus = response.code(),
|
||||
extra = apiError
|
||||
)
|
||||
}
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
NetworkResult.Error(
|
||||
code = "NO_CONNECTION",
|
||||
message = "No internet connection. Your data is shown from cache.",
|
||||
httpStatus = 0
|
||||
)
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
NetworkResult.Error(
|
||||
code = "TIMEOUT",
|
||||
message = "The request timed out. Please check your connection and try again.",
|
||||
httpStatus = 0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
NetworkResult.Error(
|
||||
code = "INTERNAL_ERROR",
|
||||
message = "Something unexpected happened. Please try again.",
|
||||
httpStatus = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.pantree.app.data.remote
|
||||
|
||||
import com.pantree.app.data.model.*
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
interface PantreeApiService {
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────
|
||||
|
||||
@POST("v1/auth/signup")
|
||||
suspend fun signup(@Body request: SignupRequest): Response<AuthResponse>
|
||||
|
||||
@POST("v1/auth/signin")
|
||||
suspend fun signin(@Body request: SigninRequest): Response<AuthResponse>
|
||||
|
||||
@POST("v1/auth/google")
|
||||
suspend fun googleAuth(@Body request: GoogleAuthRequest): Response<AuthResponse>
|
||||
|
||||
@POST("v1/auth/password-reset")
|
||||
suspend fun requestPasswordReset(@Body request: PasswordResetRequest): Response<MessageResponse>
|
||||
|
||||
@PUT("v1/auth/password-reset")
|
||||
suspend fun confirmPasswordReset(@Body request: PasswordResetConfirmRequest): Response<MessageResponse>
|
||||
|
||||
@DELETE("v1/auth/account")
|
||||
suspend fun deleteAccount(): Response<Unit>
|
||||
|
||||
@POST("v1/auth/restore-account")
|
||||
suspend fun restoreAccount(): Response<RestoreAccountResponse>
|
||||
|
||||
// ─── Pantry ──────────────────────────────────────────────────────────────
|
||||
|
||||
@GET("v1/pantry")
|
||||
suspend fun getPantryItems(): Response<PantryListResponse>
|
||||
|
||||
@POST("v1/pantry")
|
||||
suspend fun addPantryItem(@Body request: AddPantryItemRequest): Response<PantryItemResponse>
|
||||
|
||||
@PUT("v1/pantry/{item_id}")
|
||||
suspend fun updatePantryItem(
|
||||
@Path("item_id") itemId: String,
|
||||
@Body request: UpdatePantryItemRequest
|
||||
): Response<PantryItemResponse>
|
||||
|
||||
@DELETE("v1/pantry/{item_id}")
|
||||
suspend fun deletePantryItem(@Path("item_id") itemId: String): Response<Unit>
|
||||
|
||||
// ─── Recipes ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GET("v1/recipes")
|
||||
suspend fun getRecipes(
|
||||
@Query("filter") filter: String? = null,
|
||||
@Query("scale") scale: Int? = null
|
||||
): Response<RecipeListResponse>
|
||||
|
||||
@GET("v1/recipes/{recipe_id}")
|
||||
suspend fun getRecipeDetail(
|
||||
@Path("recipe_id") recipeId: String,
|
||||
@Query("scale") scale: Int? = null
|
||||
): Response<RecipeDetailResponse>
|
||||
|
||||
// ─── Shopping Lists ───────────────────────────────────────────────────────
|
||||
|
||||
@GET("v1/shopping-lists")
|
||||
suspend fun getShoppingLists(): Response<ShoppingListsResponse>
|
||||
|
||||
@POST("v1/shopping-lists")
|
||||
suspend fun createShoppingList(@Body request: CreateShoppingListRequest): Response<CreateShoppingListResponse>
|
||||
|
||||
@GET("v1/shopping-lists/{list_id}")
|
||||
suspend fun getShoppingListDetail(@Path("list_id") listId: String): Response<ShoppingListDetailResponse>
|
||||
|
||||
@DELETE("v1/shopping-lists/{list_id}")
|
||||
suspend fun deleteShoppingList(@Path("list_id") listId: String): Response<Unit>
|
||||
|
||||
@POST("v1/shopping-lists/{list_id}/items")
|
||||
suspend fun addShoppingItem(
|
||||
@Path("list_id") listId: String,
|
||||
@Body request: AddShoppingItemRequest
|
||||
): Response<AddShoppingItemResponse>
|
||||
|
||||
@POST("v1/shopping-lists/{list_id}/add-recipes")
|
||||
suspend fun addRecipesToList(
|
||||
@Path("list_id") listId: String,
|
||||
@Body request: AddRecipesToListRequest
|
||||
): Response<AddRecipesToListResponse>
|
||||
|
||||
@PUT("v1/shopping-lists/{list_id}/items/{item_id}")
|
||||
suspend fun updateShoppingItem(
|
||||
@Path("list_id") listId: String,
|
||||
@Path("item_id") itemId: String,
|
||||
@Body request: UpdateShoppingItemRequest
|
||||
): Response<UpdateShoppingItemResponse>
|
||||
|
||||
@DELETE("v1/shopping-lists/{list_id}/items/{item_id}")
|
||||
suspend fun deleteShoppingItem(
|
||||
@Path("list_id") listId: String,
|
||||
@Path("item_id") itemId: String
|
||||
): Response<Unit>
|
||||
|
||||
// ─── Sync ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@GET("v1/sync")
|
||||
suspend fun sync(@Query("since") since: String? = null): Response<SyncResponse>
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.pantree.app.data.repository
|
||||
|
||||
import com.pantree.app.data.local.TokenManager
|
||||
import com.pantree.app.data.model.*
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.remote.PantreeApiService
|
||||
import com.pantree.app.data.remote.safeApiCall
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val api: PantreeApiService,
|
||||
private val tokenManager: TokenManager
|
||||
) {
|
||||
|
||||
suspend fun signup(email: String, password: String, name: String): NetworkResult<AuthResponse> {
|
||||
val result = safeApiCall { api.signup(SignupRequest(email, password, name)) }
|
||||
if (result is NetworkResult.Success) {
|
||||
persistSession(result.data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun signin(email: String, password: String): NetworkResult<AuthResponse> {
|
||||
val result = safeApiCall { api.signin(SigninRequest(email, password)) }
|
||||
if (result is NetworkResult.Success) {
|
||||
persistSession(result.data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun googleAuth(idToken: String): NetworkResult<AuthResponse> {
|
||||
val result = safeApiCall { api.googleAuth(GoogleAuthRequest(idToken)) }
|
||||
if (result is NetworkResult.Success) {
|
||||
persistSession(result.data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun requestPasswordReset(email: String): NetworkResult<MessageResponse> =
|
||||
safeApiCall { api.requestPasswordReset(PasswordResetRequest(email)) }
|
||||
|
||||
suspend fun confirmPasswordReset(token: String, newPassword: String): NetworkResult<MessageResponse> =
|
||||
safeApiCall { api.confirmPasswordReset(PasswordResetConfirmRequest(token, newPassword)) }
|
||||
|
||||
suspend fun deleteAccount(): NetworkResult<Unit> {
|
||||
val result = safeApiCall { api.deleteAccount() }
|
||||
if (result is NetworkResult.Success) {
|
||||
tokenManager.clearAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreAccount(): NetworkResult<RestoreAccountResponse> =
|
||||
safeApiCall { api.restoreAccount() }
|
||||
|
||||
fun isLoggedIn(): Boolean = tokenManager.isTokenValid()
|
||||
|
||||
fun signOut() = tokenManager.clearAll()
|
||||
|
||||
private fun persistSession(auth: AuthResponse) {
|
||||
tokenManager.saveToken(auth.token, auth.expiresAt)
|
||||
tokenManager.saveUserInfo(
|
||||
userId = auth.user.id,
|
||||
email = auth.user.email,
|
||||
name = auth.user.name,
|
||||
profilePicUrl = auth.user.profilePictureUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.pantree.app.data.repository
|
||||
|
||||
import com.pantree.app.data.local.dao.PantryDao
|
||||
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||
import com.pantree.app.data.model.*
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.remote.PantreeApiService
|
||||
import com.pantree.app.data.remote.safeApiCall
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PantryRepository @Inject constructor(
|
||||
private val api: PantreeApiService,
|
||||
private val pantryDao: PantryDao
|
||||
) {
|
||||
|
||||
/** Live stream of pantry items from local cache. Always available, even offline. */
|
||||
fun observePantryItems(): Flow<List<PantryItemEntity>> = pantryDao.observeAll()
|
||||
|
||||
/** Fetch from server and refresh local cache. Returns error on failure. */
|
||||
suspend fun refreshPantry(): NetworkResult<PantryListResponse> {
|
||||
val result = safeApiCall { api.getPantryItems() }
|
||||
if (result is NetworkResult.Success) {
|
||||
val entities = result.data.items.map { it.toEntity() }
|
||||
pantryDao.deleteAll()
|
||||
pantryDao.insertAll(entities)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun addItem(itemName: String, quantity: Int): NetworkResult<PantryItemResponse> {
|
||||
val result = safeApiCall {
|
||||
api.addPantryItem(AddPantryItemRequest(itemName, quantity))
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
pantryDao.insert(result.data.item.toEntity())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun updateItem(itemId: String, quantity: Int): NetworkResult<PantryItemResponse> {
|
||||
val result = safeApiCall {
|
||||
api.updatePantryItem(
|
||||
itemId,
|
||||
UpdatePantryItemRequest(quantity, Instant.now().toString())
|
||||
)
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
pantryDao.insert(result.data.item.toEntity())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun deleteItem(itemId: String): NetworkResult<Unit> {
|
||||
val result = safeApiCall { api.deletePantryItem(itemId) }
|
||||
if (result is NetworkResult.Success) {
|
||||
pantryDao.deleteById(itemId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun PantryItemDto.toEntity() = PantryItemEntity(
|
||||
id = id,
|
||||
itemName = itemName,
|
||||
quantity = quantity,
|
||||
lastModified = lastModified,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.pantree.app.data.repository
|
||||
|
||||
import com.pantree.app.data.local.dao.RecipeCacheDao
|
||||
import com.pantree.app.data.local.entity.RecipeCacheEntity
|
||||
import com.pantree.app.data.model.*
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.remote.PantreeApiService
|
||||
import com.pantree.app.data.remote.safeApiCall
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RecipeRepository @Inject constructor(
|
||||
private val api: PantreeApiService,
|
||||
private val recipeCacheDao: RecipeCacheDao
|
||||
) {
|
||||
private val gson = Gson()
|
||||
|
||||
fun observeRecipes(): Flow<List<RecipeCacheEntity>> = recipeCacheDao.observeAll()
|
||||
|
||||
suspend fun refreshRecipes(filter: String? = null): NetworkResult<RecipeListResponse> {
|
||||
val result = safeApiCall { api.getRecipes(filter = filter) }
|
||||
if (result is NetworkResult.Success && filter == null) {
|
||||
// Only replace full cache on unfiltered fetch
|
||||
val entities = result.data.recipes.map { it.toEntity() }
|
||||
recipeCacheDao.deleteAll()
|
||||
recipeCacheDao.insertAll(entities)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getRecipeDetail(recipeId: String, scale: Int? = null): NetworkResult<RecipeDetailResponse> =
|
||||
safeApiCall { api.getRecipeDetail(recipeId, scale) }
|
||||
|
||||
private fun RecipeSummaryDto.toEntity() = RecipeCacheEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
servings = servings,
|
||||
ingredientCount = ingredientCount,
|
||||
availabilityStatus = availability.status,
|
||||
availableCount = availability.availableCount,
|
||||
totalCount = availability.totalCount,
|
||||
missingIngredientsJson = gson.toJson(availability.missingIngredients)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.pantree.app.data.repository
|
||||
|
||||
import com.pantree.app.data.local.dao.ShoppingListDao
|
||||
import com.pantree.app.data.local.dao.ShoppingListItemDao
|
||||
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||
import com.pantree.app.data.model.*
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.remote.PantreeApiService
|
||||
import com.pantree.app.data.remote.safeApiCall
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ShoppingRepository @Inject constructor(
|
||||
private val api: PantreeApiService,
|
||||
private val shoppingListDao: ShoppingListDao,
|
||||
private val shoppingListItemDao: ShoppingListItemDao
|
||||
) {
|
||||
|
||||
fun observeShoppingLists(): Flow<List<ShoppingListEntity>> = shoppingListDao.observeAll()
|
||||
|
||||
fun observeListItems(listId: String): Flow<List<ShoppingListItemEntity>> =
|
||||
shoppingListItemDao.observeByListId(listId)
|
||||
|
||||
suspend fun refreshShoppingLists(): NetworkResult<ShoppingListsResponse> {
|
||||
val result = safeApiCall { api.getShoppingLists() }
|
||||
if (result is NetworkResult.Success) {
|
||||
val entities = result.data.shoppingLists.map { it.toEntity() }
|
||||
shoppingListDao.deleteAll()
|
||||
shoppingListDao.insertAll(entities)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getListDetail(listId: String): NetworkResult<ShoppingListDetailResponse> {
|
||||
val result = safeApiCall { api.getShoppingListDetail(listId) }
|
||||
if (result is NetworkResult.Success) {
|
||||
val items = result.data.shoppingList.items.map { it.toEntity(listId) }
|
||||
shoppingListItemDao.deleteByListId(listId)
|
||||
shoppingListItemDao.insertAll(items)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun createList(listName: String): NetworkResult<CreateShoppingListResponse> {
|
||||
val result = safeApiCall { api.createShoppingList(CreateShoppingListRequest(listName)) }
|
||||
if (result is NetworkResult.Success) {
|
||||
shoppingListDao.insert(result.data.shoppingList.toEntity())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun deleteList(listId: String): NetworkResult<Unit> {
|
||||
val result = safeApiCall { api.deleteShoppingList(listId) }
|
||||
if (result is NetworkResult.Success) {
|
||||
shoppingListDao.deleteById(listId)
|
||||
shoppingListItemDao.deleteByListId(listId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun addItem(
|
||||
listId: String,
|
||||
itemName: String,
|
||||
quantity: Double,
|
||||
unit: String
|
||||
): NetworkResult<AddShoppingItemResponse> {
|
||||
val result = safeApiCall {
|
||||
api.addShoppingItem(listId, AddShoppingItemRequest(itemName, quantity, unit))
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
shoppingListItemDao.insert(result.data.item.toEntity(listId))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun addRecipesToList(
|
||||
listId: String,
|
||||
recipeIds: List<String>,
|
||||
scale: Int = 1
|
||||
): NetworkResult<AddRecipesToListResponse> {
|
||||
val result = safeApiCall {
|
||||
api.addRecipesToList(listId, AddRecipesToListRequest(recipeIds, scale))
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
val items = result.data.shoppingList.items.map { it.toEntity(listId) }
|
||||
shoppingListItemDao.deleteByListId(listId)
|
||||
shoppingListItemDao.insertAll(items)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun updateItem(
|
||||
listId: String,
|
||||
itemId: String,
|
||||
quantity: Double? = null,
|
||||
unit: String? = null,
|
||||
checkedOff: Boolean? = null
|
||||
): NetworkResult<UpdateShoppingItemResponse> {
|
||||
val result = safeApiCall {
|
||||
api.updateShoppingItem(listId, itemId, UpdateShoppingItemRequest(quantity, unit, checkedOff))
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
shoppingListItemDao.insert(result.data.item.toEntity(listId))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun deleteItem(listId: String, itemId: String): NetworkResult<Unit> {
|
||||
val result = safeApiCall { api.deleteShoppingItem(listId, itemId) }
|
||||
if (result is NetworkResult.Success) {
|
||||
shoppingListItemDao.deleteById(itemId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Mappers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun ShoppingListSummaryDto.toEntity() = ShoppingListEntity(
|
||||
id = id,
|
||||
listName = listName,
|
||||
itemCount = itemCount,
|
||||
checkedCount = checkedCount,
|
||||
lastModified = lastModified,
|
||||
createdAt = createdAt
|
||||
)
|
||||
|
||||
private fun ShoppingListItemDto.toEntity(listId: String) = ShoppingListItemEntity(
|
||||
id = id,
|
||||
shoppingListId = listId,
|
||||
itemName = itemName,
|
||||
quantity = quantity,
|
||||
unit = unit,
|
||||
checkedOff = checkedOff,
|
||||
lastModified = lastModified
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.pantree.app.data.repository
|
||||
|
||||
import com.pantree.app.data.local.TokenManager
|
||||
import com.pantree.app.data.local.dao.PantryDao
|
||||
import com.pantree.app.data.local.dao.ShoppingListDao
|
||||
import com.pantree.app.data.local.dao.ShoppingListItemDao
|
||||
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||
import com.pantree.app.data.model.SyncResponse
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.remote.PantreeApiService
|
||||
import com.pantree.app.data.remote.safeApiCall
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SyncRepository @Inject constructor(
|
||||
private val api: PantreeApiService,
|
||||
private val tokenManager: TokenManager,
|
||||
private val pantryDao: PantryDao,
|
||||
private val shoppingListDao: ShoppingListDao,
|
||||
private val shoppingListItemDao: ShoppingListItemDao
|
||||
) {
|
||||
|
||||
/**
|
||||
* Performs a full or delta sync depending on whether a last-sync timestamp exists.
|
||||
* On full sync: replaces all local data.
|
||||
* On delta sync: applies only changed/deleted records.
|
||||
*/
|
||||
suspend fun sync(): NetworkResult<SyncResponse> {
|
||||
val since = tokenManager.getLastSyncTimestamp()
|
||||
val result = safeApiCall { api.sync(since) }
|
||||
|
||||
if (result is NetworkResult.Success) {
|
||||
val data = result.data
|
||||
|
||||
if (data.fullSync) {
|
||||
// Full sync — replace everything
|
||||
pantryDao.deleteAll()
|
||||
shoppingListDao.deleteAll()
|
||||
shoppingListItemDao.deleteAll()
|
||||
}
|
||||
|
||||
// Apply pantry changes
|
||||
val toInsert = data.pantry.items.filter { !it.deleted }
|
||||
val toDelete = data.pantry.items.filter { it.deleted }
|
||||
|
||||
pantryDao.insertAll(toInsert.map {
|
||||
PantryItemEntity(
|
||||
id = it.id,
|
||||
itemName = it.itemName,
|
||||
quantity = it.quantity,
|
||||
lastModified = it.lastModified,
|
||||
createdAt = it.lastModified // fallback for delta sync
|
||||
)
|
||||
})
|
||||
toDelete.forEach { pantryDao.deleteById(it.id) }
|
||||
|
||||
// Apply shopping list changes
|
||||
for (list in data.shoppingLists) {
|
||||
if (list.deleted) {
|
||||
shoppingListDao.deleteById(list.id)
|
||||
shoppingListItemDao.deleteByListId(list.id)
|
||||
} else {
|
||||
val itemCount = list.items.count { !it.deleted }
|
||||
val checkedCount = list.items.count { !it.deleted && it.checkedOff }
|
||||
shoppingListDao.insert(
|
||||
ShoppingListEntity(
|
||||
id = list.id,
|
||||
listName = list.listName,
|
||||
itemCount = itemCount,
|
||||
checkedCount = checkedCount,
|
||||
lastModified = list.lastModified,
|
||||
createdAt = list.lastModified
|
||||
)
|
||||
)
|
||||
val itemsToInsert = list.items.filter { !it.deleted }
|
||||
val itemsToDelete = list.items.filter { it.deleted }
|
||||
|
||||
shoppingListItemDao.insertAll(itemsToInsert.map {
|
||||
ShoppingListItemEntity(
|
||||
id = it.id,
|
||||
shoppingListId = list.id,
|
||||
itemName = it.itemName,
|
||||
quantity = it.quantity,
|
||||
unit = it.unit,
|
||||
checkedOff = it.checkedOff,
|
||||
lastModified = it.lastModified
|
||||
)
|
||||
})
|
||||
itemsToDelete.forEach { shoppingListItemDao.deleteById(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
tokenManager.saveLastSyncTimestamp(data.serverTimestamp)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
79
android/app/src/main/java/com/pantree/app/di/AppModule.kt
Normal file
79
android/app/src/main/java/com/pantree/app/di/AppModule.kt
Normal file
@@ -0,0 +1,79 @@
|
||||
package com.pantree.app.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.pantree.app.data.local.PantreeDatabase
|
||||
import com.pantree.app.data.local.TokenManager
|
||||
import com.pantree.app.data.local.dao.*
|
||||
import com.pantree.app.data.remote.AuthInterceptor
|
||||
import com.pantree.app.data.remote.PantreeApiService
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
// ─── Network ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
|
||||
Retrofit.Builder()
|
||||
.baseUrl("https://api.pantree.app/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePantreeApiService(retrofit: Retrofit): PantreeApiService =
|
||||
retrofit.create(PantreeApiService::class.java)
|
||||
|
||||
// ─── Database ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePantreeDatabase(@ApplicationContext context: Context): PantreeDatabase =
|
||||
Room.databaseBuilder(
|
||||
context,
|
||||
PantreeDatabase::class.java,
|
||||
"pantree.db"
|
||||
).fallbackToDestructiveMigration().build()
|
||||
|
||||
@Provides
|
||||
fun providePantryDao(db: PantreeDatabase): PantryDao = db.pantryDao()
|
||||
|
||||
@Provides
|
||||
fun provideShoppingListDao(db: PantreeDatabase): ShoppingListDao = db.shoppingListDao()
|
||||
|
||||
@Provides
|
||||
fun provideShoppingListItemDao(db: PantreeDatabase): ShoppingListItemDao = db.shoppingListItemDao()
|
||||
|
||||
@Provides
|
||||
fun provideRecipeCacheDao(db: PantreeDatabase): RecipeCacheDao = db.recipeCacheDao()
|
||||
}
|
||||
699
android/app/src/main/java/com/pantree/app/ui/auth/AuthScreens.kt
Normal file
699
android/app/src/main/java/com/pantree/app/ui/auth/AuthScreens.kt
Normal file
@@ -0,0 +1,699 @@
|
||||
package com.pantree.app.ui.auth
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SIGN IN SCREEN
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SignInScreen(
|
||||
uiState: AuthUiState,
|
||||
onSignIn: (email: String, password: String) -> Unit,
|
||||
onNavigateToSignUp: () -> Unit,
|
||||
onNavigateToForgotPassword: () -> Unit,
|
||||
onGoogleSignIn: () -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
// Field-level validation
|
||||
var emailError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun validate(): Boolean {
|
||||
var valid = true
|
||||
emailError = if (email.isBlank()) {
|
||||
valid = false; "Email is required"
|
||||
} else if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
|
||||
valid = false; "Enter a valid email address"
|
||||
} else null
|
||||
|
||||
passwordError = if (password.isBlank()) {
|
||||
valid = false; "Password is required"
|
||||
} else null
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
// Logo / wordmark
|
||||
Text(
|
||||
text = "Pantree",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Your kitchen, organised.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Error banner
|
||||
AnimatedVisibility(visible = uiState.errorMessage != null) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
ErrorBanner(message = msg, onDismiss = onClearError)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Email field
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it; emailError = null; onClearError() },
|
||||
label = { Text("Email") },
|
||||
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||
isError = emailError != null,
|
||||
supportingText = emailError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Password field
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; passwordError = null; onClearError() },
|
||||
label = { Text("Password") },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
isError = passwordError != null,
|
||||
supportingText = passwordError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onSignIn(email.trim(), password)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Forgot password
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
TextButton(onClick = onNavigateToForgotPassword) {
|
||||
Text("Forgot password?")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Sign in button
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onSignIn(email.trim(), password)
|
||||
},
|
||||
enabled = !uiState.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Sign in", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Divider
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = " or ",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Google sign-in
|
||||
OutlinedButton(
|
||||
onClick = onGoogleSignIn,
|
||||
enabled = !uiState.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccountCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Continue with Google", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Sign up link
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Don't have an account?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
TextButton(onClick = onNavigateToSignUp) {
|
||||
Text("Sign up")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SIGN UP SCREEN
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SignUpScreen(
|
||||
uiState: AuthUiState,
|
||||
onSignUp: (email: String, password: String, name: String) -> Unit,
|
||||
onNavigateToSignIn: () -> Unit,
|
||||
onGoogleSignIn: () -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
var name by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
var nameError by remember { mutableStateOf<String?>(null) }
|
||||
var emailError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun validate(): Boolean {
|
||||
var valid = true
|
||||
nameError = if (name.isBlank()) {
|
||||
valid = false; "Name is required"
|
||||
} else if (name.trim().length > 100) {
|
||||
valid = false; "Name must be 100 characters or fewer"
|
||||
} else null
|
||||
|
||||
emailError = if (email.isBlank()) {
|
||||
valid = false; "Email is required"
|
||||
} else if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
|
||||
valid = false; "Enter a valid email address"
|
||||
} else null
|
||||
|
||||
passwordError = if (password.length < 8) {
|
||||
valid = false; "Password must be at least 8 characters"
|
||||
} else if (!password.any { it.isLetter() } || !password.any { it.isDigit() }) {
|
||||
valid = false; "Password must contain letters and numbers"
|
||||
} else null
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Text(
|
||||
text = "Create account",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Let's get your pantry set up.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
AnimatedVisibility(visible = uiState.errorMessage != null) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
ErrorBanner(message = msg, onDismiss = onClearError)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it; nameError = null; onClearError() },
|
||||
label = { Text("Full name") },
|
||||
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||
isError = nameError != null,
|
||||
supportingText = nameError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
capitalization = KeyboardCapitalization.Words
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it; emailError = null; onClearError() },
|
||||
label = { Text("Email") },
|
||||
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||
isError = emailError != null,
|
||||
supportingText = emailError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; passwordError = null; onClearError() },
|
||||
label = { Text("Password") },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
isError = passwordError != null,
|
||||
supportingText = passwordError?.let { { Text(it) } } ?: { Text("8+ characters, letters and numbers") },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onSignUp(email.trim(), password, name.trim())
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onSignUp(email.trim(), password, name.trim())
|
||||
},
|
||||
enabled = !uiState.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Create account", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
Text(" or ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onGoogleSignIn,
|
||||
enabled = !uiState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
Icon(Icons.Default.AccountCircle, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Continue with Google", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Already have an account?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
TextButton(onClick = onNavigateToSignIn) { Text("Sign in") }
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FORGOT PASSWORD SCREEN
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun ForgotPasswordScreen(
|
||||
uiState: AuthUiState,
|
||||
onRequestReset: (email: String) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
var email by remember { mutableStateOf("") }
|
||||
var emailError by remember { mutableStateOf<String?>(null) }
|
||||
var emailSent by remember { mutableStateOf(false) }
|
||||
|
||||
// Watch for success event via uiState — screen shows confirmation inline
|
||||
LaunchedEffect(uiState.successMessage) {
|
||||
if (uiState.successMessage != null) emailSent = true
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
if (emailSent) {
|
||||
// Success state
|
||||
Icon(
|
||||
imageVector = Icons.Default.MarkEmailRead,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Check your inbox",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "If an account exists for $email, we've sent a reset link. It expires in 1 hour.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(onClick = onNavigateBack, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
||||
Text("Back to sign in")
|
||||
}
|
||||
} else {
|
||||
// Request state
|
||||
Icon(
|
||||
imageVector = Icons.Default.LockReset,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Reset your password",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Enter your email and we'll send you a reset link.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
AnimatedVisibility(visible = uiState.errorMessage != null) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
ErrorBanner(message = msg, onDismiss = onClearError)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it; emailError = null; onClearError() },
|
||||
label = { Text("Email") },
|
||||
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||
isError = emailError != null,
|
||||
supportingText = emailError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (email.isBlank()) {
|
||||
emailError = "Email is required"
|
||||
} else {
|
||||
onRequestReset(email.trim())
|
||||
}
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
if (email.isBlank()) {
|
||||
emailError = "Email is required"
|
||||
} else {
|
||||
onRequestReset(email.trim())
|
||||
}
|
||||
},
|
||||
enabled = !uiState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
|
||||
} else {
|
||||
Text("Send reset link", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Back to sign in")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ACCOUNT RESTORE SCREEN
|
||||
// Shown when user signs in on a soft-deleted account.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AccountRestoreScreen(
|
||||
uiState: AuthUiState,
|
||||
deletionScheduledAt: String,
|
||||
onRestore: () -> Unit,
|
||||
onSignOut: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RestoreFromTrash,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Your account is scheduled for deletion",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "It will be permanently deleted on ${formatDeletionDate(deletionScheduledAt)}. " +
|
||||
"Restore it now to keep all your data.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
AnimatedVisibility(visible = uiState.errorMessage != null) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ErrorBanner(message = msg, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onRestore,
|
||||
enabled = !uiState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
|
||||
} else {
|
||||
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Restore my account", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onSignOut,
|
||||
enabled = !uiState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
Text("No thanks, sign out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SHARED COMPONENTS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.size(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Dismiss",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDeletionDate(isoTimestamp: String): String {
|
||||
return try {
|
||||
val instant = java.time.Instant.parse(isoTimestamp)
|
||||
val local = instant.atZone(java.time.ZoneId.systemDefault())
|
||||
java.time.format.DateTimeFormatter.ofPattern("MMMM d, yyyy").format(local)
|
||||
} catch (e: Exception) {
|
||||
isoTimestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.pantree.app.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
// ─── UI State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
data class AuthUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val successMessage: String? = null,
|
||||
// For account-pending-deletion flow
|
||||
val pendingDeletionScheduledAt: String? = null,
|
||||
val canRestore: Boolean = false
|
||||
)
|
||||
|
||||
sealed class AuthEvent {
|
||||
object NavigateToHome : AuthEvent()
|
||||
data class NavigateToRestore(val deletionScheduledAt: String) : AuthEvent()
|
||||
object NavigateToSignIn : AuthEvent()
|
||||
object PasswordResetEmailSent : AuthEvent()
|
||||
object PasswordResetComplete : AuthEvent()
|
||||
object AccountDeleted : AuthEvent()
|
||||
object AccountRestored : AuthEvent()
|
||||
}
|
||||
|
||||
// ─── ViewModel ────────────────────────────────────────────────────────────────
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AuthUiState())
|
||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<AuthEvent>()
|
||||
val events: SharedFlow<AuthEvent> = _events.asSharedFlow()
|
||||
|
||||
fun isLoggedIn(): Boolean = authRepository.isLoggedIn()
|
||||
|
||||
fun signup(email: String, password: String, name: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (val result = authRepository.signup(email, password, name)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.NavigateToHome)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = friendlyError(result.code, result.message)
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun signin(email: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (val result = authRepository.signin(email, password)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.NavigateToHome)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
if (result.code == "ACCOUNT_PENDING_DELETION") {
|
||||
val scheduledAt = result.extra?.deletionScheduledAt ?: ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
pendingDeletionScheduledAt = scheduledAt,
|
||||
canRestore = result.extra?.canRestore ?: false
|
||||
)
|
||||
}
|
||||
_events.emit(AuthEvent.NavigateToRestore(scheduledAt))
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = friendlyError(result.code, result.message)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun googleAuth(idToken: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (val result = authRepository.googleAuth(idToken)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.NavigateToHome)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = friendlyError(result.code, result.message)
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (authRepository.requestPasswordReset(email)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.PasswordResetEmailSent)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
// Server always returns 200 — this only fires on network failure
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Couldn't send the reset email. Check your connection and try again."
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmPasswordReset(token: String, newPassword: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (val result = authRepository.confirmPasswordReset(token, newPassword)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.PasswordResetComplete)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = friendlyError(result.code, result.message)
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAccount() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (authRepository.deleteAccount()) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.AccountDeleted)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Couldn't delete your account right now. Please try again."
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreAccount() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (val result = authRepository.restoreAccount()) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.AccountRestored)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
val message = if (result.httpStatus == 410) {
|
||||
"This account has been permanently deleted and can't be recovered."
|
||||
} else {
|
||||
"Couldn't restore your account. Please try again."
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = message) }
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
authRepository.signOut()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
private fun friendlyError(code: String, serverMessage: String): String = when (code) {
|
||||
"CONFLICT" -> "An account with that email already exists. Try signing in instead."
|
||||
"UNAUTHORIZED" -> "That email and password don't match. Double-check and try again."
|
||||
"INVALID_TOKEN" -> "That link has expired or already been used. Request a new one."
|
||||
"VALIDATION_ERROR" -> serverMessage
|
||||
"NO_CONNECTION" -> "No internet connection. Please check your network."
|
||||
"TIMEOUT" -> "The request timed out. Please try again."
|
||||
else -> serverMessage.ifBlank { "Something went wrong. Please try again." }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package com.pantree.app.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LOADING STATE
|
||||
// Used on every screen while data is being fetched.
|
||||
// Not a spinner in the middle of nowhere — it has context.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun LoadingState(
|
||||
message: String = "Loading…",
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// INLINE LOADING (for buttons, small areas)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun InlineLoading(modifier: Modifier = Modifier) {
|
||||
CircularProgressIndicator(
|
||||
modifier = modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ERROR STATE
|
||||
// Honest about what went wrong. Gives the user something to do about it.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ErrorOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Something went wrong",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (onRetry != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Try again")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EMPTY STATE
|
||||
// The pantry is empty. The list is empty. That's fine — tell them what to do.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
actionLabel: String? = null,
|
||||
onAction: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (actionLabel != null && onAction != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Button(onClick = onAction) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OFFLINE BANNER
|
||||
// Shown at the top of screens when there's no connection.
|
||||
// Read-only mode — no edits while offline.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun OfflineBanner(modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.WifiOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = "You're offline. Showing saved data — changes are paused.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SYNC INDICATOR
|
||||
// Subtle. Doesn't interrupt. Just lets them know something is happening.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SyncingIndicator(
|
||||
isSyncing: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isSyncing,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically(),
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "Syncing…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SNACKBAR HOST — used for transient feedback
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun PantreeSnackbarHost(
|
||||
hostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SnackbarHost(
|
||||
hostState = hostState,
|
||||
modifier = modifier,
|
||||
snackbar = { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
actionColor = MaterialTheme.colorScheme.inversePrimary,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CONFIRM DELETE DIALOG
|
||||
// "Are you sure?" — but written like a human, not a legal document.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun ConfirmDeleteDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
confirmLabel: String = "Delete",
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeleteForever,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION HEADER
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SectionHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
package com.pantree.app.ui.navigation
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.*
|
||||
import androidx.navigation.compose.*
|
||||
import com.pantree.app.ui.auth.*
|
||||
import com.pantree.app.ui.pantry.*
|
||||
import com.pantree.app.ui.recipes.*
|
||||
import com.pantree.app.ui.settings.*
|
||||
import com.pantree.app.ui.shopping.*
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import java.net.URLDecoder
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOTTOM NAV ITEMS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
data class BottomNavItem(
|
||||
val screen: Screen,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val selectedIcon: ImageVector = icon
|
||||
)
|
||||
|
||||
val bottomNavItems = listOf(
|
||||
BottomNavItem(Screen.Pantry, "Pantry", Icons.Default.Kitchen),
|
||||
BottomNavItem(Screen.Recipes, "Recipes", Icons.Default.MenuBook),
|
||||
BottomNavItem(Screen.ShoppingLists, "Lists", Icons.Default.ShoppingCart),
|
||||
BottomNavItem(Screen.Settings, "Settings", Icons.Default.Settings)
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOT NAV HOST
|
||||
// Decides whether to show auth or main flow based on token validity.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun PantreeNavHost(
|
||||
isLoggedIn: Boolean,
|
||||
isOffline: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val startDestination = if (isLoggedIn) "main" else "auth"
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = modifier,
|
||||
enterTransition = { fadeIn() + slideInHorizontally { it / 4 } },
|
||||
exitTransition = { fadeOut() + slideOutHorizontally { -it / 4 } },
|
||||
popEnterTransition = { fadeIn() + slideInHorizontally { -it / 4 } },
|
||||
popExitTransition = { fadeOut() + slideOutHorizontally { it / 4 } }
|
||||
) {
|
||||
// ── Auth graph ────────────────────────────────────────────────────────
|
||||
navigation(startDestination = Screen.SignIn.route, route = "auth") {
|
||||
composable(Screen.SignIn.route) {
|
||||
val viewModel: AuthViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is AuthEvent.NavigateToHome -> {
|
||||
navController.navigate("main") {
|
||||
popUpTo("auth") { inclusive = true }
|
||||
}
|
||||
}
|
||||
is AuthEvent.NavigateToRestore -> {
|
||||
navController.navigate(
|
||||
Screen.AccountRestore.createRoute(event.deletionScheduledAt)
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignInScreen(
|
||||
uiState = uiState,
|
||||
onSignIn = { email, password -> viewModel.signin(email, password) },
|
||||
onNavigateToSignUp = { navController.navigate(Screen.SignUp.route) },
|
||||
onNavigateToForgotPassword = { navController.navigate(Screen.ForgotPassword.route) },
|
||||
onGoogleSignIn = { /* Google Sign-In launcher handled at Activity level */ },
|
||||
onClearError = viewModel::clearError
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.SignUp.route) {
|
||||
val viewModel: AuthViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is AuthEvent.NavigateToHome -> {
|
||||
navController.navigate("main") {
|
||||
popUpTo("auth") { inclusive = true }
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignUpScreen(
|
||||
uiState = uiState,
|
||||
onSignUp = { email, password, name -> viewModel.signup(email, password, name) },
|
||||
onNavigateToSignIn = { navController.popBackStack() },
|
||||
onGoogleSignIn = { /* Google Sign-In launcher */ },
|
||||
onClearError = viewModel::clearError
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.ForgotPassword.route) {
|
||||
val viewModel: AuthViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is AuthEvent.PasswordResetEmailSent -> {
|
||||
// Screen handles success state inline — no nav needed
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForgotPasswordScreen(
|
||||
uiState = uiState,
|
||||
onRequestReset = { email -> viewModel.requestPasswordReset(email) },
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onClearError = viewModel::clearError
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.AccountRestore.route,
|
||||
arguments = listOf(navArgument("deletion_scheduled_at") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val viewModel: AuthViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val deletionScheduledAt = URLDecoder.decode(
|
||||
backStackEntry.arguments?.getString("deletion_scheduled_at") ?: "",
|
||||
"UTF-8"
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is AuthEvent.AccountRestored -> {
|
||||
navController.navigate("main") {
|
||||
popUpTo("auth") { inclusive = true }
|
||||
}
|
||||
}
|
||||
is AuthEvent.AccountDeleted -> {
|
||||
navController.navigate(Screen.SignIn.route) {
|
||||
popUpTo("auth") { inclusive = false }
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AccountRestoreScreen(
|
||||
uiState = uiState,
|
||||
deletionScheduledAt = deletionScheduledAt,
|
||||
onRestore = viewModel::restoreAccount,
|
||||
onSignOut = {
|
||||
viewModel.signOut()
|
||||
navController.navigate(Screen.SignIn.route) {
|
||||
popUpTo("auth") { inclusive = false }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main graph (bottom nav) ───────────────────────────────────────────
|
||||
navigation(startDestination = Screen.Pantry.route, route = "main") {
|
||||
composable(Screen.Pantry.route) {
|
||||
val viewModel: PantryViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is PantryEvent.ItemAdded -> snackbarHostState.showSnackbar("\"${event.itemName}\" added to pantry")
|
||||
is PantryEvent.ItemUpdated -> snackbarHostState.showSnackbar("Quantity updated")
|
||||
is PantryEvent.ItemDeleted -> snackbarHostState.showSnackbar("\"${event.itemName}\" removed")
|
||||
is PantryEvent.Error -> snackbarHostState.showSnackbar(event.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PantryScreen(
|
||||
uiState = uiState,
|
||||
onRefresh = viewModel::refresh,
|
||||
onAddItem = viewModel::addItem,
|
||||
onUpdateItem = viewModel::updateItem,
|
||||
onDeleteItem = viewModel::deleteItem,
|
||||
onClearError = viewModel::clearError,
|
||||
onClearSnackbar = viewModel::clearSnackbar,
|
||||
onClearDuplicateConflict = viewModel::clearDuplicateConflict,
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Recipes.route) {
|
||||
val viewModel: RecipesViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
RecipesScreen(
|
||||
uiState = uiState,
|
||||
onRefresh = viewModel::refresh,
|
||||
onFilterChange = viewModel::setFilter,
|
||||
onSearchChange = viewModel::setSearchQuery,
|
||||
onRecipeClick = { recipeId ->
|
||||
viewModel.loadRecipeDetail(recipeId)
|
||||
navController.navigate(Screen.RecipeDetail.createRoute(recipeId))
|
||||
},
|
||||
onClearError = viewModel::clearError,
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.RecipeDetail.route,
|
||||
arguments = listOf(navArgument("recipe_id") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val recipeId = backStackEntry.arguments?.getString("recipe_id") ?: return@composable
|
||||
// Share the ViewModel with the recipes list screen via the nav back stack entry
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry(Screen.Recipes.route)
|
||||
}
|
||||
val viewModel: RecipesViewModel = hiltViewModel(parentEntry)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val shoppingViewModel: ShoppingViewModel = hiltViewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
shoppingViewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is ShoppingEvent.RecipesAdded -> {
|
||||
// Navigate back to shopping lists after adding
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecipeDetailScreen(
|
||||
recipeId = recipeId,
|
||||
uiState = uiState,
|
||||
onBack = {
|
||||
viewModel.clearDetail()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onScaleChange = { scale -> viewModel.setScale(recipeId, scale) },
|
||||
onAddToShoppingList = { rId, scale ->
|
||||
// Navigate to shopping lists to pick a list
|
||||
navController.navigate(Screen.ShoppingLists.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.ShoppingLists.route) {
|
||||
val viewModel: ShoppingViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is ShoppingEvent.ListCreated -> {
|
||||
navController.navigate(
|
||||
Screen.ShoppingListDetail.createRoute(event.listId, event.listName)
|
||||
)
|
||||
}
|
||||
is ShoppingEvent.ListDeleted -> snackbarHostState.showSnackbar("\"${event.listName}\" deleted")
|
||||
is ShoppingEvent.Error -> snackbarHostState.showSnackbar(event.message)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ShoppingListsScreen(
|
||||
uiState = uiState,
|
||||
onRefresh = viewModel::refreshLists,
|
||||
onCreateList = viewModel::createList,
|
||||
onDeleteList = viewModel::deleteList,
|
||||
onListClick = { id, name ->
|
||||
viewModel.loadListDetail(id)
|
||||
navController.navigate(Screen.ShoppingListDetail.createRoute(id, name))
|
||||
},
|
||||
onClearError = viewModel::clearListsError,
|
||||
onClearSnackbar = viewModel::clearSnackbar,
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.ShoppingListDetail.route,
|
||||
arguments = listOf(
|
||||
navArgument("list_id") { type = NavType.StringType },
|
||||
navArgument("list_name") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val listId = backStackEntry.arguments?.getString("list_id") ?: return@composable
|
||||
val listName = URLDecoder.decode(
|
||||
backStackEntry.arguments?.getString("list_name") ?: "",
|
||||
"UTF-8"
|
||||
)
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry(Screen.ShoppingLists.route)
|
||||
}
|
||||
val viewModel: ShoppingViewModel = hiltViewModel(parentEntry)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is ShoppingEvent.ItemAdded -> {
|
||||
val msg = if (event.merged) "Quantities merged for \"${event.itemName}\""
|
||||
else "\"${event.itemName}\" added"
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
is ShoppingEvent.ItemDeleted -> snackbarHostState.showSnackbar("\"${event.itemName}\" removed")
|
||||
is ShoppingEvent.ListDeleted -> navController.popBackStack()
|
||||
is ShoppingEvent.Error -> snackbarHostState.showSnackbar(event.message)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ShoppingListDetailScreen(
|
||||
listId = listId,
|
||||
uiState = uiState.copy(currentListName = uiState.currentListName.ifBlank { listName }),
|
||||
onBack = { navController.popBackStack() },
|
||||
onAddItem = { name, qty, unit -> viewModel.addItem(listId, name, qty, unit) },
|
||||
onToggleItem = { item -> viewModel.toggleItemChecked(listId, item) },
|
||||
onDeleteItem = { item -> viewModel.deleteItem(listId, item) },
|
||||
onDeleteList = { viewModel.deleteList(listId, listName) },
|
||||
onClearError = viewModel::clearDetailError,
|
||||
onClearSnackbar = viewModel::clearSnackbar,
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Settings.route) {
|
||||
val viewModel: SettingsViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is SettingsEvent.SignedOut, is SettingsEvent.AccountDeleted -> {
|
||||
navController.navigate("auth") {
|
||||
popUpTo("main") { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsScreen(
|
||||
uiState = uiState,
|
||||
onSyncNow = viewModel::syncNow,
|
||||
onSignOut = viewModel::signOut,
|
||||
onDeleteAccount = viewModel::deleteAccount,
|
||||
onShowDeleteConfirm = viewModel::showDeleteConfirm,
|
||||
onHideDeleteConfirm = viewModel::hideDeleteConfirm,
|
||||
onClearError = viewModel::clearError,
|
||||
onClearSnackbar = viewModel::clearSnackbar
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MAIN SCAFFOLD WITH BOTTOM NAV
|
||||
// Wraps the main graph screens with persistent bottom navigation.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun MainScaffold(
|
||||
isOffline: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val currentBackStack by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = currentBackStack?.destination?.route
|
||||
|
||||
val showBottomBar = bottomNavItems.any { it.screen.route == currentRoute }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
NavigationBar {
|
||||
bottomNavItems.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == item.screen.route,
|
||||
onClick = {
|
||||
navController.navigate(item.screen.route) {
|
||||
popUpTo(navController.graph.startDestinationId) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = if (currentRoute == item.screen.route)
|
||||
item.selectedIcon else item.icon,
|
||||
contentDescription = item.label
|
||||
)
|
||||
},
|
||||
label = { Text(item.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Pantry.route,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
enterTransition = { fadeIn() },
|
||||
exitTransition = { fadeOut() }
|
||||
) {
|
||||
composable(Screen.Pantry.route) {
|
||||
val viewModel: PantryViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
PantryScreen(
|
||||
uiState = uiState,
|
||||
onRefresh = viewModel::refresh,
|
||||
onAddItem = viewModel::addItem,
|
||||
onUpdateItem = viewModel::updateItem,
|
||||
onDeleteItem = viewModel::deleteItem,
|
||||
onClearError = viewModel::clearError,
|
||||
onClearSnackbar = viewModel::clearSnackbar,
|
||||
onClearDuplicateConflict = viewModel::clearDuplicateConflict,
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
composable(Screen.Recipes.route) {
|
||||
val viewModel: RecipesViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
RecipesScreen(
|
||||
uiState = uiState,
|
||||
onRefresh = viewModel::refresh,
|
||||
onFilterChange = viewModel::setFilter,
|
||||
onSearchChange = viewModel::setSearchQuery,
|
||||
onRecipeClick = { recipeId ->
|
||||
viewModel.loadRecipeDetail(recipeId)
|
||||
navController.navigate(Screen.RecipeDetail.createRoute(recipeId))
|
||||
},
|
||||
onClearError = viewModel::clearError,
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.RecipeDetail.route,
|
||||
arguments = listOf(navArgument("recipe_id") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val recipeId = backStackEntry.arguments?.getString("recipe_id") ?: return@composable
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry(Screen.Recipes.route)
|
||||
}
|
||||
val viewModel: RecipesViewModel = hiltViewModel(parentEntry)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
RecipeDetailScreen(
|
||||
recipeId = recipeId,
|
||||
uiState = uiState,
|
||||
onBack = { viewModel.clearDetail(); navController.popBackStack() },
|
||||
onScaleChange = { scale -> viewModel.setScale(recipeId, scale) },
|
||||
onAddToShoppingList = { _, _ -> navController.navigate(Screen.ShoppingLists.route) }
|
||||
)
|
||||
}
|
||||
composable(Screen.ShoppingLists.route) {
|
||||
val viewModel: ShoppingViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
if (event is ShoppingEvent.ListCreated) {
|
||||
navController.navigate(
|
||||
Screen.ShoppingListDetail.createRoute(event.listId, event.listName)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ShoppingListsScreen(
|
||||
uiState = uiState,
|
||||
onRefresh = viewModel::refreshLists,
|
||||
onCreateList = viewModel::createList,
|
||||
onDeleteList = viewModel::deleteList,
|
||||
onListClick = { id, name ->
|
||||
viewModel.loadListDetail(id)
|
||||
navController.navigate(Screen.ShoppingListDetail.createRoute(id, name))
|
||||
},
|
||||
onClearError = viewModel::clearListsError,
|
||||
onClearSnackbar = viewModel::clearSnackbar,
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.ShoppingListDetail.route,
|
||||
arguments = listOf(
|
||||
navArgument("list_id") { type = NavType.StringType },
|
||||
navArgument("list_name") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val listId = backStackEntry.arguments?.getString("list_id") ?: return@composable
|
||||
val listName = URLDecoder.decode(
|
||||
backStackEntry.arguments?.getString("list_name") ?: "", "UTF-8"
|
||||
)
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry(Screen.ShoppingLists.route)
|
||||
}
|
||||
val viewModel: ShoppingViewModel = hiltViewModel(parentEntry)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
if (event is ShoppingEvent.ListDeleted) navController.popBackStack()
|
||||
}
|
||||
}
|
||||
ShoppingListDetailScreen(
|
||||
listId = listId,
|
||||
uiState = uiState.copy(currentListName = uiState.currentListName.ifBlank { listName }),
|
||||
onBack = { navController.popBackStack() },
|
||||
onAddItem = { name, qty, unit -> viewModel.addItem(listId, name, qty, unit) },
|
||||
onToggleItem = { item -> viewModel.toggleItemChecked(listId, item) },
|
||||
onDeleteItem = { item -> viewModel.deleteItem(listId, item) },
|
||||
onDeleteList = { viewModel.deleteList(listId, listName) },
|
||||
onClearError = viewModel::clearDetailError,
|
||||
onClearSnackbar = viewModel::clearSnackbar,
|
||||
isOffline = isOffline
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
val viewModel: SettingsViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is SettingsEvent.SignedOut, is SettingsEvent.AccountDeleted -> {
|
||||
navController.navigate("auth") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsScreen(
|
||||
uiState = uiState,
|
||||
onSyncNow = viewModel::syncNow,
|
||||
onSignOut = viewModel::signOut,
|
||||
onDeleteAccount = viewModel::deleteAccount,
|
||||
onShowDeleteConfirm = viewModel::showDeleteConfirm,
|
||||
onHideDeleteConfirm = viewModel::hideDeleteConfirm,
|
||||
onClearError = viewModel::clearError,
|
||||
onClearSnackbar = viewModel::clearSnackbar
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.pantree.app.ui.navigation
|
||||
|
||||
// All named routes in the app.
|
||||
// Sealed class keeps them in one place — no magic strings scattered around.
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
// Auth
|
||||
object SignIn : Screen("sign_in")
|
||||
object SignUp : Screen("sign_up")
|
||||
object ForgotPassword : Screen("forgot_password")
|
||||
object AccountRestore : Screen("account_restore/{deletion_scheduled_at}") {
|
||||
fun createRoute(deletionScheduledAt: String) =
|
||||
"account_restore/${java.net.URLEncoder.encode(deletionScheduledAt, "UTF-8")}"
|
||||
}
|
||||
|
||||
// Main (bottom nav)
|
||||
object Pantry : Screen("pantry")
|
||||
object Recipes : Screen("recipes")
|
||||
object RecipeDetail : Screen("recipe_detail/{recipe_id}") {
|
||||
fun createRoute(recipeId: String) = "recipe_detail/$recipeId"
|
||||
}
|
||||
object ShoppingLists : Screen("shopping_lists")
|
||||
object ShoppingListDetail : Screen("shopping_list_detail/{list_id}/{list_name}") {
|
||||
fun createRoute(listId: String, listName: String) =
|
||||
"shopping_list_detail/$listId/${java.net.URLEncoder.encode(listName, "UTF-8")}"
|
||||
}
|
||||
object Settings : Screen("settings")
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
package com.pantree.app.ui.pantry
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||
import com.pantree.app.ui.components.*
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PANTRY SCREEN — the main list
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PantryScreen(
|
||||
uiState: PantryUiState,
|
||||
onRefresh: () -> Unit,
|
||||
onAddItem: (name: String, quantity: Int) -> Unit,
|
||||
onUpdateItem: (id: String, quantity: Int) -> Unit,
|
||||
onDeleteItem: (PantryItemEntity) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
onClearSnackbar: () -> Unit,
|
||||
onClearDuplicateConflict: () -> Unit,
|
||||
isOffline: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showAddSheet by remember { mutableStateOf(false) }
|
||||
var editingItem by remember { mutableStateOf<PantryItemEntity?>(null) }
|
||||
var deleteTarget by remember { mutableStateOf<PantryItemEntity?>(null) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
|
||||
// Show snackbar messages
|
||||
LaunchedEffect(uiState.snackbarMessage) {
|
||||
uiState.snackbarMessage?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
onClearSnackbar()
|
||||
}
|
||||
}
|
||||
|
||||
val filteredItems = remember(uiState.items, searchQuery) {
|
||||
if (searchQuery.isBlank()) uiState.items
|
||||
else uiState.items.filter { it.itemName.contains(searchQuery, ignoreCase = true) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Pantry",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh pantry")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
if (isOffline) OfflineBanner()
|
||||
if (uiState.isRefreshing) SyncingIndicator(isSyncing = true)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!isOffline) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddSheet = true },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text("Add item") },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { PantreeSnackbarHost(hostState = snackbarHostState) },
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Search bar — only shown when there are items
|
||||
if (uiState.items.isNotEmpty()) {
|
||||
SearchBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Error banner
|
||||
AnimatedVisibility(visible = uiState.errorMessage != null) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = msg,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onClearError, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Dismiss",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
when {
|
||||
uiState.isRefreshing && uiState.items.isEmpty() -> {
|
||||
LoadingState(message = "Loading your pantry…")
|
||||
}
|
||||
uiState.items.isEmpty() && !uiState.isRefreshing -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.Kitchen,
|
||||
title = "Your pantry is empty",
|
||||
subtitle = "Add ingredients you have on hand and we'll show you what you can cook.",
|
||||
actionLabel = if (!isOffline) "Add your first item" else null,
|
||||
onAction = if (!isOffline) ({ showAddSheet = true }) else null
|
||||
)
|
||||
}
|
||||
filteredItems.isEmpty() && searchQuery.isNotBlank() -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.SearchOff,
|
||||
title = "No results for \"$searchQuery\"",
|
||||
subtitle = "Try a different name, or add it as a new item.",
|
||||
actionLabel = if (!isOffline) "Add \"$searchQuery\"" else null,
|
||||
onAction = if (!isOffline) ({
|
||||
showAddSheet = true
|
||||
}) else null
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
PantryItemList(
|
||||
items = filteredItems,
|
||||
isOffline = isOffline,
|
||||
onEdit = { editingItem = it },
|
||||
onDelete = { deleteTarget = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add Item Bottom Sheet ──────────────────────────────────────────────────
|
||||
if (showAddSheet) {
|
||||
AddPantryItemSheet(
|
||||
isLoading = uiState.isLoading,
|
||||
prefillName = if (searchQuery.isNotBlank() &&
|
||||
uiState.items.none { it.itemName.equals(searchQuery, ignoreCase = true) }
|
||||
) searchQuery else "",
|
||||
onAdd = { name, qty ->
|
||||
onAddItem(name, qty)
|
||||
showAddSheet = false
|
||||
},
|
||||
onDismiss = { showAddSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Edit Item Bottom Sheet ─────────────────────────────────────────────────
|
||||
editingItem?.let { item ->
|
||||
EditPantryItemSheet(
|
||||
item = item,
|
||||
isLoading = uiState.isLoading,
|
||||
onUpdate = { qty ->
|
||||
onUpdateItem(item.id, qty)
|
||||
editingItem = null
|
||||
},
|
||||
onDismiss = { editingItem = null }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Delete Confirmation ────────────────────────────────────────────────────
|
||||
deleteTarget?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
title = "Remove from pantry?",
|
||||
message = "\"${item.itemName}\" will be removed from your pantry. You can always add it back.",
|
||||
confirmLabel = "Remove",
|
||||
onConfirm = {
|
||||
onDeleteItem(item)
|
||||
deleteTarget = null
|
||||
},
|
||||
onDismiss = { deleteTarget = null }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Duplicate Conflict Dialog ──────────────────────────────────────────────
|
||||
uiState.duplicateConflict?.let { conflict ->
|
||||
AlertDialog(
|
||||
onDismissRequest = onClearDuplicateConflict,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
},
|
||||
title = { Text("Already in your pantry") },
|
||||
text = {
|
||||
Text(
|
||||
"\"${conflict.attemptedName}\" is already in your pantry. " +
|
||||
"Tap the item to update its quantity instead.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onClearDuplicateConflict) { Text("Got it") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PANTRY ITEM LIST
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PantryItemList(
|
||||
items: List<PantryItemEntity>,
|
||||
isOffline: Boolean,
|
||||
onEdit: (PantryItemEntity) -> Unit,
|
||||
onDelete: (PantryItemEntity) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 96.dp) // FAB clearance
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "${items.size} item${if (items.size != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
items(items = items, key = { it.id }) { item ->
|
||||
PantryItemRow(
|
||||
item = item,
|
||||
isOffline = isOffline,
|
||||
onEdit = { onEdit(item) },
|
||||
onDelete = { onDelete(item) },
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PANTRY ITEM ROW — swipe to delete, tap to edit
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PantryItemRow(
|
||||
item: PantryItemEntity,
|
||||
isOffline: Boolean,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart && !isOffline) {
|
||||
onDelete()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
)
|
||||
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
enableDismissFromEndToStart = !isOffline,
|
||||
backgroundContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = { if (!isOffline) onEdit() },
|
||||
onLongClick = { if (!isOffline) onDelete() }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Item icon / avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item.itemName.first().uppercaseChar().toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.itemName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Qty: ${item.quantity}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOffline) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ADD ITEM BOTTOM SHEET
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddPantryItemSheet(
|
||||
isLoading: Boolean,
|
||||
prefillName: String = "",
|
||||
onAdd: (name: String, quantity: Int) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val focusManager = LocalFocusManager.current
|
||||
val nameFocusRequester = remember { FocusRequester() }
|
||||
|
||||
var name by remember { mutableStateOf(prefillName) }
|
||||
var quantityText by remember { mutableStateOf("1") }
|
||||
var nameError by remember { mutableStateOf<String?>(null) }
|
||||
var quantityError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) { nameFocusRequester.requestFocus() }
|
||||
|
||||
fun validate(): Boolean {
|
||||
var valid = true
|
||||
nameError = if (name.isBlank()) { valid = false; "Item name is required" } else null
|
||||
quantityError = when {
|
||||
quantityText.isBlank() -> { valid = false; "Quantity is required" }
|
||||
quantityText.toIntOrNull() == null -> { valid = false; "Enter a whole number" }
|
||||
(quantityText.toIntOrNull() ?: 0) <= 0 -> { valid = false; "Quantity must be at least 1" }
|
||||
else -> null
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Add to pantry",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it; nameError = null },
|
||||
label = { Text("Item name") },
|
||||
leadingIcon = { Icon(Icons.Default.LocalGroceryStore, contentDescription = null) },
|
||||
isError = nameError != null,
|
||||
supportingText = nameError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(nameFocusRequester)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = quantityText,
|
||||
onValueChange = { quantityText = it; quantityError = null },
|
||||
label = { Text("Quantity") },
|
||||
leadingIcon = { Icon(Icons.Default.Numbers, contentDescription = null) },
|
||||
isError = quantityError != null,
|
||||
supportingText = quantityError?.let { { Text(it) } } ?: { Text("Whole numbers only") },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onAdd(name.trim(), quantityText.toInt())
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f).height(52.dp)
|
||||
) { Text("Cancel") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onAdd(name.trim(), quantityText.toInt())
|
||||
},
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.weight(1f).height(52.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
|
||||
} else {
|
||||
Text("Add to pantry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EDIT ITEM BOTTOM SHEET
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EditPantryItemSheet(
|
||||
item: PantryItemEntity,
|
||||
isLoading: Boolean,
|
||||
onUpdate: (quantity: Int) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val focusManager = LocalFocusManager.current
|
||||
var quantityText by remember { mutableStateOf(item.quantity.toString()) }
|
||||
var quantityError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun validate(): Boolean {
|
||||
quantityError = when {
|
||||
quantityText.isBlank() -> "Quantity is required"
|
||||
quantityText.toIntOrNull() == null -> "Enter a whole number"
|
||||
(quantityText.toIntOrNull() ?: 0) <= 0 -> "Quantity must be at least 1"
|
||||
else -> null
|
||||
}
|
||||
return quantityError == null
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Edit quantity",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(
|
||||
text = item.itemName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Item name can't be changed — delete and re-add if you need a different name.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = quantityText,
|
||||
onValueChange = { quantityText = it; quantityError = null },
|
||||
label = { Text("Quantity") },
|
||||
leadingIcon = { Icon(Icons.Default.Numbers, contentDescription = null) },
|
||||
isError = quantityError != null,
|
||||
supportingText = quantityError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onUpdate(quantityText.toInt())
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f).height(52.dp)
|
||||
) { Text("Cancel") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onUpdate(quantityText.toInt())
|
||||
},
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.weight(1f).height(52.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SEARCH BAR
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search pantry…") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotBlank()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear search")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.pantree.app.ui.pantry
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.repository.PantryRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
// ─── UI State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
data class PantryUiState(
|
||||
val items: List<PantryItemEntity> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val snackbarMessage: String? = null,
|
||||
// Duplicate-item conflict: server told us the item already exists
|
||||
val duplicateConflict: DuplicateConflict? = null
|
||||
)
|
||||
|
||||
data class DuplicateConflict(
|
||||
val attemptedName: String,
|
||||
val existingItem: PantryItemEntity? = null
|
||||
)
|
||||
|
||||
sealed class PantryEvent {
|
||||
data class ItemAdded(val itemName: String) : PantryEvent()
|
||||
data class ItemUpdated(val itemName: String) : PantryEvent()
|
||||
data class ItemDeleted(val itemName: String) : PantryEvent()
|
||||
data class Error(val message: String) : PantryEvent()
|
||||
}
|
||||
|
||||
// ─── ViewModel ────────────────────────────────────────────────────────────────
|
||||
|
||||
@HiltViewModel
|
||||
class PantryViewModel @Inject constructor(
|
||||
private val pantryRepository: PantryRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PantryUiState())
|
||||
val uiState: StateFlow<PantryUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<PantryEvent>()
|
||||
val events: SharedFlow<PantryEvent> = _events.asSharedFlow()
|
||||
|
||||
init {
|
||||
// Observe local cache — always up to date, even offline
|
||||
viewModelScope.launch {
|
||||
pantryRepository.observePantryItems().collect { items ->
|
||||
_uiState.update { it.copy(items = items) }
|
||||
}
|
||||
}
|
||||
// Initial refresh from server
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isRefreshing = true, errorMessage = null) }
|
||||
when (val result = pantryRepository.refreshPantry()) {
|
||||
is NetworkResult.Error -> {
|
||||
// Don't wipe the screen — cached data is still shown
|
||||
if (result.code != "NO_CONNECTION") {
|
||||
_uiState.update { it.copy(errorMessage = result.message) }
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(snackbarMessage = "Offline — showing saved data")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
_uiState.update { it.copy(isRefreshing = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addItem(itemName: String, quantity: Int) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (val result = pantryRepository.addItem(itemName.trim(), quantity)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(PantryEvent.ItemAdded(itemName.trim()))
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
if (result.code == "DUPLICATE_ITEM") {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
duplicateConflict = DuplicateConflict(attemptedName = itemName.trim())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateItem(itemId: String, quantity: Int) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
when (val result = pantryRepository.updateItem(itemId, quantity)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(PantryEvent.ItemUpdated(result.data.item.itemName))
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = result.message)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(item: PantryItemEntity) {
|
||||
viewModelScope.launch {
|
||||
when (val result = pantryRepository.deleteItem(item.id)) {
|
||||
is NetworkResult.Success -> {
|
||||
_events.emit(PantryEvent.ItemDeleted(item.itemName))
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update { it.copy(errorMessage = result.message) }
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDuplicateConflict() {
|
||||
_uiState.update { it.copy(duplicateConflict = null) }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
fun clearSnackbar() {
|
||||
_uiState.update { it.copy(snackbarMessage = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package com.pantree.app.ui.recipes
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pantree.app.data.local.entity.RecipeCacheEntity
|
||||
import com.pantree.app.data.model.RecipeDetailDto
|
||||
import com.pantree.app.data.model.RecipeIngredientDto
|
||||
import com.pantree.app.ui.components.*
|
||||
import com.pantree.app.ui.theme.*
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RECIPES LIST SCREEN
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecipesScreen(
|
||||
uiState: RecipesUiState,
|
||||
onRefresh: () -> Unit,
|
||||
onFilterChange: (RecipeFilter) -> Unit,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onRecipeClick: (String) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
isOffline: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("Recipes", style = MaterialTheme.typography.headlineMedium)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh recipes")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
if (isOffline) OfflineBanner()
|
||||
if (uiState.isRefreshing) SyncingIndicator(isSyncing = true)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Search
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = onSearchChange,
|
||||
placeholder = { Text("Search recipes…") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (uiState.searchQuery.isNotBlank()) {
|
||||
IconButton(onClick = { onSearchChange("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
// Filter chips
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
items(RecipeFilter.values()) { filter ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedFilter == filter,
|
||||
onClick = { onFilterChange(filter) },
|
||||
label = { Text(filter.label) },
|
||||
leadingIcon = if (uiState.selectedFilter == filter) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
AnimatedVisibility(visible = uiState.errorMessage != null) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Error, null, tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(18.dp))
|
||||
Text(msg, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onClearError, modifier = Modifier.size(24.dp)) {
|
||||
Icon(Icons.Default.Close, "Dismiss", tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
when {
|
||||
uiState.isRefreshing && uiState.recipes.isEmpty() -> {
|
||||
LoadingState(message = "Loading recipes…")
|
||||
}
|
||||
uiState.recipes.isEmpty() && !uiState.isRefreshing -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.MenuBook,
|
||||
title = "No recipes yet",
|
||||
subtitle = "Recipes will appear here once they're loaded from the server."
|
||||
)
|
||||
}
|
||||
uiState.filteredRecipes.isEmpty() -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.SearchOff,
|
||||
title = when (uiState.selectedFilter) {
|
||||
RecipeFilter.CAN_MAKE -> "Nothing to cook right now"
|
||||
RecipeFilter.PARTIAL -> "No partial matches"
|
||||
RecipeFilter.ALL -> "No results for \"${uiState.searchQuery}\""
|
||||
},
|
||||
subtitle = when (uiState.selectedFilter) {
|
||||
RecipeFilter.CAN_MAKE -> "Add more ingredients to your pantry to unlock recipes."
|
||||
RecipeFilter.PARTIAL -> "Try adding more pantry items."
|
||||
RecipeFilter.ALL -> "Try a different search term."
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
RecipeList(
|
||||
recipes = uiState.filteredRecipes,
|
||||
onRecipeClick = onRecipeClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RECIPE LIST
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun RecipeList(
|
||||
recipes: List<RecipeCacheEntity>,
|
||||
onRecipeClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "${recipes.size} recipe${if (recipes.size != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
items(recipes, key = { it.id }) { recipe ->
|
||||
RecipeCard(
|
||||
recipe = recipe,
|
||||
onClick = { onRecipeClick(recipe.id) },
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RECIPE CARD
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun RecipeCard(
|
||||
recipe: RecipeCacheEntity,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Availability indicator dot
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(availabilityColor(recipe.availabilityStatus))
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = recipe.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Servings
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(Icons.Default.People, null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${recipe.servings}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
// Ingredient count
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(Icons.Default.Egg, null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${recipe.ingredientCount} ingredients", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
AvailabilityChip(
|
||||
status = recipe.availabilityStatus,
|
||||
availableCount = recipe.availableCount,
|
||||
totalCount = recipe.totalCount
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RECIPE DETAIL SCREEN
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecipeDetailScreen(
|
||||
recipeId: String,
|
||||
uiState: RecipesUiState,
|
||||
onBack: () -> Unit,
|
||||
onScaleChange: (Int) -> Unit,
|
||||
onAddToShoppingList: (recipeId: String, scale: Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = uiState.selectedRecipe?.name ?: "Recipe",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
when {
|
||||
uiState.isLoadingDetail -> {
|
||||
LoadingState(
|
||||
message = "Loading recipe…",
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
uiState.detailError != null -> {
|
||||
ErrorState(
|
||||
message = uiState.detailError,
|
||||
onRetry = null,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
uiState.selectedRecipe != null -> {
|
||||
RecipeDetailContent(
|
||||
recipe = uiState.selectedRecipe,
|
||||
selectedScale = uiState.selectedScale,
|
||||
onScaleChange = onScaleChange,
|
||||
onAddToShoppingList = { onAddToShoppingList(recipeId, uiState.selectedScale) },
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LoadingState(
|
||||
message = "Loading recipe…",
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RECIPE DETAIL CONTENT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun RecipeDetailContent(
|
||||
recipe: RecipeDetailDto,
|
||||
selectedScale: Int,
|
||||
onScaleChange: (Int) -> Unit,
|
||||
onAddToShoppingList: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 96.dp)
|
||||
) {
|
||||
// Header
|
||||
item {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)) {
|
||||
// Availability summary
|
||||
AvailabilityChip(
|
||||
status = recipe.availability.status,
|
||||
availableCount = recipe.availability.availableCount,
|
||||
totalCount = recipe.availability.totalCount
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Servings + scale
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.People, null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = "${recipe.scaledServings} serving${if (recipe.scaledServings != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Scale selector
|
||||
Text(
|
||||
text = "Scale recipe",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(1, 2, 3).forEach { scale ->
|
||||
FilterChip(
|
||||
selected = selectedScale == scale,
|
||||
onClick = { onScaleChange(scale) },
|
||||
label = { Text("${scale}×") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ingredients section
|
||||
item {
|
||||
SectionHeader(title = "Ingredients", modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
items(recipe.ingredients) { ingredient ->
|
||||
IngredientRow(ingredient = ingredient)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Instructions section
|
||||
item {
|
||||
SectionHeader(title = "Instructions", modifier = Modifier.padding(top = 16.dp))
|
||||
Text(
|
||||
text = recipe.instructions,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Add to shopping list button
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onAddToShoppingList,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(52.dp)
|
||||
) {
|
||||
Icon(Icons.Default.AddShoppingCart, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Add ingredients to shopping list")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// INGREDIENT ROW
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun IngredientRow(
|
||||
ingredient: RecipeIngredientDto,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// In-pantry indicator
|
||||
Icon(
|
||||
imageVector = if (ingredient.inPantry) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = if (ingredient.inPantry) "In pantry" else "Not in pantry",
|
||||
tint = if (ingredient.inPantry) CanMakeGreen else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text = ingredient.itemName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = formatQuantity(ingredient.quantity, ingredient.unit),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AVAILABILITY CHIP
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AvailabilityChip(
|
||||
status: String,
|
||||
availableCount: Int,
|
||||
totalCount: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val (color, icon, label) = when (status) {
|
||||
"can_make" -> Triple(CanMakeGreen, Icons.Default.CheckCircle, "Can make")
|
||||
"partial" -> Triple(PartialYellow, Icons.Default.RemoveCircle, "$availableCount / $totalCount ingredients")
|
||||
else -> Triple(MissingRed, Icons.Default.Cancel, "Missing ingredients")
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = color.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(14.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun availabilityColor(status: String): Color = when (status) {
|
||||
"can_make" -> CanMakeGreen
|
||||
"partial" -> PartialYellow
|
||||
else -> MissingRed
|
||||
}
|
||||
|
||||
private fun formatQuantity(quantity: Double, unit: String): String {
|
||||
val formatted = if (quantity == quantity.toLong().toDouble()) {
|
||||
quantity.toLong().toString()
|
||||
} else {
|
||||
"%.2f".format(quantity).trimEnd('0').trimEnd('.')
|
||||
}
|
||||
return "$formatted $unit"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.pantree.app.ui.recipes
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pantree.app.data.local.entity.RecipeCacheEntity
|
||||
import com.pantree.app.data.model.RecipeDetailDto
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.repository.RecipeRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
// ─── Filter ───────────────────────────────────────────────────────────────────
|
||||
|
||||
enum class RecipeFilter(val apiValue: String?, val label: String) {
|
||||
ALL(null, "All"),
|
||||
CAN_MAKE("available", "Can make"),
|
||||
PARTIAL("partial", "Partial")
|
||||
}
|
||||
|
||||
// ─── UI State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
data class RecipesUiState(
|
||||
val recipes: List<RecipeCacheEntity> = emptyList(),
|
||||
val filteredRecipes: List<RecipeCacheEntity> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val selectedFilter: RecipeFilter = RecipeFilter.ALL,
|
||||
val searchQuery: String = "",
|
||||
// Detail state
|
||||
val selectedRecipe: RecipeDetailDto? = null,
|
||||
val isLoadingDetail: Boolean = false,
|
||||
val detailError: String? = null,
|
||||
val selectedScale: Int = 1
|
||||
)
|
||||
|
||||
sealed class RecipesEvent {
|
||||
data class NavigateToDetail(val recipeId: String) : RecipesEvent()
|
||||
data class AddedToShoppingList(val listName: String) : RecipesEvent()
|
||||
}
|
||||
|
||||
// ─── ViewModel ────────────────────────────────────────────────────────────────
|
||||
|
||||
@HiltViewModel
|
||||
class RecipesViewModel @Inject constructor(
|
||||
private val recipeRepository: RecipeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(RecipesUiState())
|
||||
val uiState: StateFlow<RecipesUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<RecipesEvent>()
|
||||
val events: SharedFlow<RecipesEvent> = _events.asSharedFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
recipeRepository.observeRecipes().collect { recipes ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
recipes = recipes,
|
||||
filteredRecipes = applyFilters(recipes, state.selectedFilter, state.searchQuery)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isRefreshing = true, errorMessage = null) }
|
||||
when (val result = recipeRepository.refreshRecipes()) {
|
||||
is NetworkResult.Error -> {
|
||||
if (result.code != "NO_CONNECTION") {
|
||||
_uiState.update { it.copy(errorMessage = result.message) }
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
_uiState.update { it.copy(isRefreshing = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setFilter(filter: RecipeFilter) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
selectedFilter = filter,
|
||||
filteredRecipes = applyFilters(state.recipes, filter, state.searchQuery)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
searchQuery = query,
|
||||
filteredRecipes = applyFilters(state.recipes, state.selectedFilter, query)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRecipeDetail(recipeId: String, scale: Int = 1) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoadingDetail = true, detailError = null, selectedScale = scale) }
|
||||
when (val result = recipeRepository.getRecipeDetail(recipeId, scale.takeIf { it > 1 })) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingDetail = false,
|
||||
selectedRecipe = result.data.recipe
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingDetail = false,
|
||||
detailError = if (result.httpStatus == 404)
|
||||
"This recipe couldn't be found."
|
||||
else result.message
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setScale(recipeId: String, scale: Int) {
|
||||
if (scale == _uiState.value.selectedScale) return
|
||||
loadRecipeDetail(recipeId, scale)
|
||||
}
|
||||
|
||||
fun clearDetail() {
|
||||
_uiState.update { it.copy(selectedRecipe = null, detailError = null, selectedScale = 1) }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
private fun applyFilters(
|
||||
recipes: List<RecipeCacheEntity>,
|
||||
filter: RecipeFilter,
|
||||
query: String
|
||||
): List<RecipeCacheEntity> {
|
||||
var result = recipes
|
||||
|
||||
result = when (filter) {
|
||||
RecipeFilter.ALL -> result
|
||||
RecipeFilter.CAN_MAKE -> result.filter { it.availabilityStatus == "can_make" }
|
||||
RecipeFilter.PARTIAL -> result.filter { it.availabilityStatus == "partial" }
|
||||
}
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
result = result.filter { it.name.contains(query, ignoreCase = true) }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.pantree.app.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.pantree.app.ui.components.*
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
uiState: SettingsUiState,
|
||||
onSyncNow: () -> Unit,
|
||||
onSignOut: () -> Unit,
|
||||
onDeleteAccount: () -> Unit,
|
||||
onShowDeleteConfirm: () -> Unit,
|
||||
onHideDeleteConfirm: () -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
onClearSnackbar: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(uiState.snackbarMessage) {
|
||||
uiState.snackbarMessage?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
onClearSnackbar()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings", style = MaterialTheme.typography.headlineMedium) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { PantreeSnackbarHost(hostState = snackbarHostState) },
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// ── Profile card ──────────────────────────────────────────────────
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Avatar
|
||||
if (uiState.profilePicUrl != null) {
|
||||
AsyncImage(
|
||||
model = uiState.profilePicUrl,
|
||||
contentDescription = "Profile picture",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier.size(56.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = uiState.userName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = uiState.userName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
text = uiState.userEmail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sync section ──────────────────────────────────────────────────
|
||||
SectionHeader(title = "Data")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Sync,
|
||||
title = "Sync now",
|
||||
subtitle = uiState.lastSyncTimestamp?.let { "Last synced: ${formatTimestamp(it)}" }
|
||||
?: "Never synced",
|
||||
onClick = onSyncNow,
|
||||
trailing = {
|
||||
if (uiState.isSyncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// ── Account section ───────────────────────────────────────────────
|
||||
SectionHeader(title = "Account", modifier = Modifier.padding(top = 8.dp))
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Logout,
|
||||
title = "Sign out",
|
||||
subtitle = "You'll need to sign in again to access your data.",
|
||||
onClick = onSignOut
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.DeleteForever,
|
||||
title = "Delete account",
|
||||
subtitle = "Your account will be scheduled for deletion. You have 15 days to change your mind.",
|
||||
onClick = onShowDeleteConfirm,
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
iconTint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────
|
||||
if (uiState.errorMessage != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Error, null, tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(18.dp))
|
||||
Text(
|
||||
uiState.errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onClearError, modifier = Modifier.size(24.dp)) {
|
||||
Icon(Icons.Default.Close, "Dismiss", tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// App version
|
||||
Text(
|
||||
text = "Pantree v1.0",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete account confirmation
|
||||
if (uiState.showDeleteConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onHideDeleteConfirm,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.DeleteForever,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = { Text("Delete your account?") },
|
||||
text = {
|
||||
Text(
|
||||
"Your account will be scheduled for deletion. You'll have 15 days to restore it " +
|
||||
"by signing back in. After that, everything is gone for good.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDeleteAccount,
|
||||
enabled = !uiState.isLoading,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
} else {
|
||||
Text("Yes, delete it")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onHideDeleteConfirm) {
|
||||
Text("Keep my account")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SETTINGS ROW
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
onClick: () -> Unit,
|
||||
titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface,
|
||||
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
trailing: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = titleColor
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
if (trailing != null) {
|
||||
trailing()
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(isoTimestamp: String): String {
|
||||
return try {
|
||||
val instant = Instant.parse(isoTimestamp)
|
||||
val local = instant.atZone(ZoneId.systemDefault())
|
||||
DateTimeFormatter.ofPattern("MMM d 'at' h:mm a").format(local)
|
||||
} catch (e: Exception) {
|
||||
isoTimestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.pantree.app.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pantree.app.data.local.TokenManager
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.repository.AuthRepository
|
||||
import com.pantree.app.data.repository.SyncRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(
|
||||
val userName: String = "",
|
||||
val userEmail: String = "",
|
||||
val profilePicUrl: String? = null,
|
||||
val lastSyncTimestamp: String? = null,
|
||||
val isSyncing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val snackbarMessage: String? = null,
|
||||
val showDeleteConfirm: Boolean = false
|
||||
)
|
||||
|
||||
sealed class SettingsEvent {
|
||||
object SignedOut : SettingsEvent()
|
||||
object AccountDeleted : SettingsEvent()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val syncRepository: SyncRepository,
|
||||
private val tokenManager: TokenManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<SettingsEvent>()
|
||||
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadUserInfo()
|
||||
}
|
||||
|
||||
private fun loadUserInfo() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
userName = tokenManager.getUserName() ?: "",
|
||||
userEmail = tokenManager.getUserEmail() ?: "",
|
||||
profilePicUrl = tokenManager.getProfilePicUrl(),
|
||||
lastSyncTimestamp = tokenManager.getLastSyncTimestamp()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun syncNow() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSyncing = true) }
|
||||
when (val result = syncRepository.sync()) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSyncing = false,
|
||||
lastSyncTimestamp = tokenManager.getLastSyncTimestamp(),
|
||||
snackbarMessage = "All caught up!"
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSyncing = false,
|
||||
snackbarMessage = if (result.code == "NO_CONNECTION")
|
||||
"Can't sync — no internet connection."
|
||||
else "Sync failed. Please try again."
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
authRepository.signOut()
|
||||
viewModelScope.launch { _events.emit(SettingsEvent.SignedOut) }
|
||||
}
|
||||
|
||||
fun showDeleteConfirm() = _uiState.update { it.copy(showDeleteConfirm = true) }
|
||||
fun hideDeleteConfirm() = _uiState.update { it.copy(showDeleteConfirm = false) }
|
||||
|
||||
fun deleteAccount() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, showDeleteConfirm = false) }
|
||||
when (authRepository.deleteAccount()) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(SettingsEvent.AccountDeleted)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Couldn't delete your account right now. Please try again."
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() = _uiState.update { it.copy(errorMessage = null) }
|
||||
fun clearSnackbar() = _uiState.update { it.copy(snackbarMessage = null) }
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package com.pantree.app.ui.shopping
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||
import com.pantree.app.ui.components.*
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SHOPPING LISTS SCREEN — the index of all lists
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShoppingListsScreen(
|
||||
uiState: ShoppingUiState,
|
||||
onRefresh: () -> Unit,
|
||||
onCreateList: (name: String) -> Unit,
|
||||
onDeleteList: (id: String, name: String) -> Unit,
|
||||
onListClick: (id: String, name: String) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
onClearSnackbar: () -> Unit,
|
||||
isOffline: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showCreateSheet by remember { mutableStateOf(false) }
|
||||
var deleteTarget by remember { mutableStateOf<ShoppingListEntity?>(null) }
|
||||
|
||||
LaunchedEffect(uiState.snackbarMessage) {
|
||||
uiState.snackbarMessage?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
onClearSnackbar()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = { Text("Shopping Lists", style = MaterialTheme.typography.headlineMedium) },
|
||||
actions = {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
if (isOffline) OfflineBanner()
|
||||
if (uiState.isRefreshingLists) SyncingIndicator(isSyncing = true)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!isOffline) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showCreateSheet = true },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text("New list") },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { PantreeSnackbarHost(hostState = snackbarHostState) },
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Error banner
|
||||
AnimatedVisibility(visible = uiState.listsError != null) {
|
||||
uiState.listsError?.let { msg ->
|
||||
ErrorBannerRow(message = msg, onDismiss = onClearError)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
uiState.isRefreshingLists && uiState.lists.isEmpty() -> {
|
||||
LoadingState(message = "Loading your lists…")
|
||||
}
|
||||
uiState.lists.isEmpty() && !uiState.isRefreshingLists -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.ShoppingCart,
|
||||
title = "No shopping lists yet",
|
||||
subtitle = "Create a list to start planning your next grocery run.",
|
||||
actionLabel = if (!isOffline) "Create your first list" else null,
|
||||
onAction = if (!isOffline) ({ showCreateSheet = true }) else null
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
top = 8.dp,
|
||||
bottom = 96.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(uiState.lists, key = { it.id }) { list ->
|
||||
ShoppingListCard(
|
||||
list = list,
|
||||
isOffline = isOffline,
|
||||
onClick = { onListClick(list.id, list.listName) },
|
||||
onDelete = { deleteTarget = list },
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create list sheet
|
||||
if (showCreateSheet) {
|
||||
CreateListSheet(
|
||||
isLoading = uiState.isOperationLoading,
|
||||
onCreate = { name ->
|
||||
onCreateList(name)
|
||||
showCreateSheet = false
|
||||
},
|
||||
onDismiss = { showCreateSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
deleteTarget?.let { list ->
|
||||
ConfirmDeleteDialog(
|
||||
title = "Delete list?",
|
||||
message = "\"${list.listName}\" and all its items will be permanently deleted.",
|
||||
confirmLabel = "Delete",
|
||||
onConfirm = {
|
||||
onDeleteList(list.id, list.listName)
|
||||
deleteTarget = null
|
||||
},
|
||||
onDismiss = { deleteTarget = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SHOPPING LIST CARD
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ShoppingListCard(
|
||||
list: ShoppingListEntity,
|
||||
isOffline: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = { if (!isOffline) onDelete() }
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Progress indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (list.itemCount > 0) {
|
||||
CircularProgressIndicator(
|
||||
progress = { list.checkedCount.toFloat() / list.itemCount.toFloat() },
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "${list.checkedCount}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.ShoppingCart,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = list.listName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (list.itemCount == 0) "Empty list"
|
||||
else "${list.checkedCount} of ${list.itemCount} checked",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SHOPPING LIST DETAIL SCREEN
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShoppingListDetailScreen(
|
||||
listId: String,
|
||||
uiState: ShoppingUiState,
|
||||
onBack: () -> Unit,
|
||||
onAddItem: (name: String, quantity: Double, unit: String) -> Unit,
|
||||
onToggleItem: (ShoppingListItemEntity) -> Unit,
|
||||
onDeleteItem: (ShoppingListItemEntity) -> Unit,
|
||||
onDeleteList: () -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
onClearSnackbar: () -> Unit,
|
||||
isOffline: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showAddSheet by remember { mutableStateOf(false) }
|
||||
var showDeleteListDialog by remember { mutableStateOf(false) }
|
||||
var deleteItemTarget by remember { mutableStateOf<ShoppingListItemEntity?>(null) }
|
||||
|
||||
val uncheckedItems = remember(uiState.items) { uiState.items.filter { !it.checkedOff } }
|
||||
val checkedItems = remember(uiState.items) { uiState.items.filter { it.checkedOff } }
|
||||
|
||||
LaunchedEffect(uiState.snackbarMessage) {
|
||||
uiState.snackbarMessage?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
onClearSnackbar()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = uiState.currentListName.ifBlank { "Shopping List" },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!isOffline) {
|
||||
IconButton(onClick = { showDeleteListDialog = true }) {
|
||||
Icon(
|
||||
Icons.Default.DeleteForever,
|
||||
contentDescription = "Delete list",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!isOffline) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddSheet = true },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text("Add item") },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { PantreeSnackbarHost(hostState = snackbarHostState) },
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
when {
|
||||
uiState.isLoadingDetail && uiState.items.isEmpty() -> {
|
||||
LoadingState(
|
||||
message = "Loading list…",
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
uiState.detailError != null -> {
|
||||
ErrorState(
|
||||
message = uiState.detailError,
|
||||
onRetry = null,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
uiState.items.isEmpty() && !uiState.isLoadingDetail -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.PlaylistAdd,
|
||||
title = "This list is empty",
|
||||
subtitle = "Add items manually or pull in ingredients from a recipe.",
|
||||
actionLabel = if (!isOffline) "Add first item" else null,
|
||||
onAction = if (!isOffline) ({ showAddSheet = true }) else null,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentPadding = PaddingValues(bottom = 96.dp)
|
||||
) {
|
||||
// Unchecked items
|
||||
if (uncheckedItems.isNotEmpty()) {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = "${uncheckedItems.size} item${if (uncheckedItems.size != 1) "s" else ""} to get"
|
||||
)
|
||||
}
|
||||
items(uncheckedItems, key = { it.id }) { item ->
|
||||
ShoppingItemRow(
|
||||
item = item,
|
||||
isOffline = isOffline,
|
||||
onToggle = { onToggleItem(item) },
|
||||
onDelete = { deleteItemTarget = item },
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Checked items
|
||||
if (checkedItems.isNotEmpty()) {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = "${checkedItems.size} checked",
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
items(checkedItems, key = { it.id }) { item ->
|
||||
ShoppingItemRow(
|
||||
item = item,
|
||||
isOffline = isOffline,
|
||||
onToggle = { onToggleItem(item) },
|
||||
onDelete = { deleteItemTarget = item },
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add item sheet
|
||||
if (showAddSheet) {
|
||||
AddShoppingItemSheet(
|
||||
isLoading = uiState.isOperationLoading,
|
||||
onAdd = { name, qty, unit ->
|
||||
onAddItem(name, qty, unit)
|
||||
showAddSheet = false
|
||||
},
|
||||
onDismiss = { showAddSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete item confirmation
|
||||
deleteItemTarget?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
title = "Remove item?",
|
||||
message = "\"${item.itemName}\" will be removed from this list.",
|
||||
confirmLabel = "Remove",
|
||||
onConfirm = {
|
||||
onDeleteItem(item)
|
||||
deleteItemTarget = null
|
||||
},
|
||||
onDismiss = { deleteItemTarget = null }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete list confirmation
|
||||
if (showDeleteListDialog) {
|
||||
ConfirmDeleteDialog(
|
||||
title = "Delete this list?",
|
||||
message = "\"${uiState.currentListName}\" and all its items will be permanently deleted.",
|
||||
confirmLabel = "Delete list",
|
||||
onConfirm = {
|
||||
onDeleteList()
|
||||
showDeleteListDialog = false
|
||||
},
|
||||
onDismiss = { showDeleteListDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SHOPPING ITEM ROW — swipe to delete, tap to toggle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ShoppingItemRow(
|
||||
item: ShoppingListItemEntity,
|
||||
isOffline: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart && !isOffline) {
|
||||
onDelete()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
)
|
||||
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
enableDismissFromEndToStart = !isOffline,
|
||||
backgroundContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = { if (!isOffline) onToggle() },
|
||||
onLongClick = { if (!isOffline) onDelete() }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = item.checkedOff,
|
||||
onCheckedChange = { if (!isOffline) onToggle() },
|
||||
enabled = !isOffline
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.itemName,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
textDecoration = if (item.checkedOff) TextDecoration.LineThrough else TextDecoration.None
|
||||
),
|
||||
color = if (item.checkedOff)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = formatQuantity(item.quantity, item.unit),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CREATE LIST SHEET
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CreateListSheet(
|
||||
isLoading: Boolean,
|
||||
onCreate: (name: String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var nameError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("New shopping list", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it; nameError = null },
|
||||
label = { Text("List name") },
|
||||
leadingIcon = { Icon(Icons.Default.ShoppingCart, contentDescription = null) },
|
||||
isError = nameError != null,
|
||||
supportingText = nameError?.let { { Text(it) } },
|
||||
placeholder = { Text("e.g. Weekly Groceries") },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (name.isBlank()) nameError = "List name is required"
|
||||
else onCreate(name.trim())
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f).height(52.dp)
|
||||
) { Text("Cancel") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
if (name.isBlank()) nameError = "List name is required"
|
||||
else onCreate(name.trim())
|
||||
},
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.weight(1f).height(52.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Create list")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ADD SHOPPING ITEM SHEET
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddShoppingItemSheet(
|
||||
isLoading: Boolean,
|
||||
onAdd: (name: String, quantity: Double, unit: String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var quantityText by remember { mutableStateOf("1") }
|
||||
var unit by remember { mutableStateOf("") }
|
||||
var nameError by remember { mutableStateOf<String?>(null) }
|
||||
var quantityError by remember { mutableStateOf<String?>(null) }
|
||||
var unitError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Common unit suggestions
|
||||
val unitSuggestions = listOf("cups", "tbsp", "tsp", "oz", "lbs", "g", "kg", "ml", "L", "whole", "pieces")
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
fun validate(): Boolean {
|
||||
var valid = true
|
||||
nameError = if (name.isBlank()) { valid = false; "Item name is required" } else null
|
||||
quantityError = when {
|
||||
quantityText.isBlank() -> { valid = false; "Quantity is required" }
|
||||
quantityText.toDoubleOrNull() == null -> { valid = false; "Enter a number" }
|
||||
(quantityText.toDoubleOrNull() ?: 0.0) <= 0.0 -> { valid = false; "Must be greater than 0" }
|
||||
else -> null
|
||||
}
|
||||
unitError = if (unit.isBlank()) { valid = false; "Unit is required (e.g. cups, oz, whole)" } else null
|
||||
return valid
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Add item", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it; nameError = null },
|
||||
label = { Text("Item name") },
|
||||
leadingIcon = { Icon(Icons.Default.LocalGroceryStore, contentDescription = null) },
|
||||
isError = nameError != null,
|
||||
supportingText = nameError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = quantityText,
|
||||
onValueChange = { quantityText = it; quantityError = null },
|
||||
label = { Text("Qty") },
|
||||
isError = quantityError != null,
|
||||
supportingText = quantityError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Decimal,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = unit,
|
||||
onValueChange = { unit = it; unitError = null },
|
||||
label = { Text("Unit") },
|
||||
isError = unitError != null,
|
||||
supportingText = unitError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onAdd(name.trim(), quantityText.toDouble(), unit.trim())
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Unit quick-pick chips
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
items(unitSuggestions) { suggestion ->
|
||||
SuggestionChip(
|
||||
onClick = { unit = suggestion; unitError = null },
|
||||
label = { Text(suggestion, style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f).height(52.dp)
|
||||
) { Text("Cancel") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
if (validate()) onAdd(name.trim(), quantityText.toDouble(), unit.trim())
|
||||
},
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.weight(1f).height(52.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Add to list")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SHARED HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ErrorBannerRow(message: String, onDismiss: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Error, null, tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(18.dp))
|
||||
Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) {
|
||||
Icon(Icons.Default.Close, "Dismiss", tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatQuantity(quantity: Double, unit: String): String {
|
||||
val formatted = if (quantity == quantity.toLong().toDouble()) {
|
||||
quantity.toLong().toString()
|
||||
} else {
|
||||
"%.2f".format(quantity).trimEnd('0').trimEnd('.')
|
||||
}
|
||||
return "$formatted $unit"
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.pantree.app.ui.shopping
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.repository.ShoppingRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
// ─── UI State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
data class ShoppingUiState(
|
||||
// Lists screen
|
||||
val lists: List<ShoppingListEntity> = emptyList(),
|
||||
val isLoadingLists: Boolean = false,
|
||||
val isRefreshingLists: Boolean = false,
|
||||
val listsError: String? = null,
|
||||
// Detail screen
|
||||
val currentListId: String? = null,
|
||||
val currentListName: String = "",
|
||||
val items: List<ShoppingListItemEntity> = emptyList(),
|
||||
val isLoadingDetail: Boolean = false,
|
||||
val detailError: String? = null,
|
||||
// Shared
|
||||
val isOperationLoading: Boolean = false,
|
||||
val snackbarMessage: String? = null
|
||||
)
|
||||
|
||||
sealed class ShoppingEvent {
|
||||
data class ListCreated(val listId: String, val listName: String) : ShoppingEvent()
|
||||
data class ListDeleted(val listName: String) : ShoppingEvent()
|
||||
data class ItemAdded(val itemName: String, val merged: Boolean) : ShoppingEvent()
|
||||
data class ItemDeleted(val itemName: String) : ShoppingEvent()
|
||||
data class RecipesAdded(val count: Int) : ShoppingEvent()
|
||||
data class Error(val message: String) : ShoppingEvent()
|
||||
}
|
||||
|
||||
// ─── ViewModel ────────────────────────────────────────────────────────────────
|
||||
|
||||
@HiltViewModel
|
||||
class ShoppingViewModel @Inject constructor(
|
||||
private val shoppingRepository: ShoppingRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ShoppingUiState())
|
||||
val uiState: StateFlow<ShoppingUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<ShoppingEvent>()
|
||||
val events: SharedFlow<ShoppingEvent> = _events.asSharedFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
shoppingRepository.observeShoppingLists().collect { lists ->
|
||||
_uiState.update { it.copy(lists = lists) }
|
||||
}
|
||||
}
|
||||
refreshLists()
|
||||
}
|
||||
|
||||
fun refreshLists() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isRefreshingLists = true, listsError = null) }
|
||||
when (val result = shoppingRepository.refreshShoppingLists()) {
|
||||
is NetworkResult.Error -> {
|
||||
if (result.code != "NO_CONNECTION") {
|
||||
_uiState.update { it.copy(listsError = result.message) }
|
||||
} else {
|
||||
_uiState.update { it.copy(snackbarMessage = "Offline — showing saved data") }
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
_uiState.update { it.copy(isRefreshingLists = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadListDetail(listId: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(currentListId = listId, isLoadingDetail = true, detailError = null) }
|
||||
|
||||
// Observe local items immediately
|
||||
shoppingRepository.observeListItems(listId).collect { items ->
|
||||
_uiState.update { it.copy(items = items) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = shoppingRepository.getListDetail(listId)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingDetail = false,
|
||||
currentListName = result.data.shoppingList.listName
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingDetail = false,
|
||||
detailError = if (result.httpStatus == 404)
|
||||
"This list couldn't be found."
|
||||
else result.message
|
||||
)
|
||||
}
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createList(listName: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isOperationLoading = true) }
|
||||
when (val result = shoppingRepository.createList(listName.trim())) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isOperationLoading = false) }
|
||||
_events.emit(ShoppingEvent.ListCreated(
|
||||
listId = result.data.shoppingList.id,
|
||||
listName = result.data.shoppingList.listName
|
||||
))
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update { it.copy(isOperationLoading = false) }
|
||||
_events.emit(ShoppingEvent.Error(result.message))
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteList(listId: String, listName: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = shoppingRepository.deleteList(listId)) {
|
||||
is NetworkResult.Success -> {
|
||||
_events.emit(ShoppingEvent.ListDeleted(listName))
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_events.emit(ShoppingEvent.Error(result.message))
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addItem(listId: String, itemName: String, quantity: Double, unit: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isOperationLoading = true) }
|
||||
when (val result = shoppingRepository.addItem(listId, itemName.trim(), quantity, unit.trim())) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isOperationLoading = false) }
|
||||
_events.emit(ShoppingEvent.ItemAdded(
|
||||
itemName = result.data.item.itemName,
|
||||
merged = result.data.merged
|
||||
))
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update { it.copy(isOperationLoading = false) }
|
||||
_events.emit(ShoppingEvent.Error(result.message))
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRecipesToList(listId: String, recipeIds: List<String>, scale: Int = 1) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isOperationLoading = true) }
|
||||
when (val result = shoppingRepository.addRecipesToList(listId, recipeIds, scale)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isOperationLoading = false) }
|
||||
_events.emit(ShoppingEvent.RecipesAdded(result.data.recipesAdded))
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update { it.copy(isOperationLoading = false) }
|
||||
_events.emit(ShoppingEvent.Error(result.message))
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleItemChecked(listId: String, item: ShoppingListItemEntity) {
|
||||
viewModelScope.launch {
|
||||
shoppingRepository.updateItem(
|
||||
listId = listId,
|
||||
itemId = item.id,
|
||||
checkedOff = !item.checkedOff
|
||||
)
|
||||
// Optimistic update — don't wait for server
|
||||
}
|
||||
}
|
||||
|
||||
fun updateItemQuantity(listId: String, itemId: String, quantity: Double, unit: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isOperationLoading = true) }
|
||||
when (val result = shoppingRepository.updateItem(listId, itemId, quantity, unit)) {
|
||||
is NetworkResult.Success -> _uiState.update { it.copy(isOperationLoading = false) }
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update { it.copy(isOperationLoading = false) }
|
||||
_events.emit(ShoppingEvent.Error(result.message))
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(listId: String, item: ShoppingListItemEntity) {
|
||||
viewModelScope.launch {
|
||||
when (val result = shoppingRepository.deleteItem(listId, item.id)) {
|
||||
is NetworkResult.Success -> {
|
||||
_events.emit(ShoppingEvent.ItemDeleted(item.itemName))
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_events.emit(ShoppingEvent.Error(result.message))
|
||||
}
|
||||
is NetworkResult.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearListsError() = _uiState.update { it.copy(listsError = null) }
|
||||
fun clearDetailError() = _uiState.update { it.copy(detailError = null) }
|
||||
fun clearSnackbar() = _uiState.update { it.copy(snackbarMessage = null) }
|
||||
}
|
||||
39
android/app/src/main/java/com/pantree/app/ui/theme/Color.kt
Normal file
39
android/app/src/main/java/com/pantree/app/ui/theme/Color.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.pantree.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ─── Pantree Brand Palette ────────────────────────────────────────────────────
|
||||
// Warm, earthy, approachable. Not a sterile medical app. Not a finance dashboard.
|
||||
// Something that feels like a kitchen.
|
||||
|
||||
val PantreeGreen = Color(0xFF3D7A5A)
|
||||
val PantreeGreenLight = Color(0xFF5A9E78)
|
||||
val PantreeGreenDark = Color(0xFF2A5740)
|
||||
|
||||
val PantreeOrange = Color(0xFFE07B39)
|
||||
val PantreeOrangeLight = Color(0xFFEA9B62)
|
||||
val PantreeOrangeDark = Color(0xFFB85E22)
|
||||
|
||||
val PantreeCream = Color(0xFFFAF6F0)
|
||||
val PantreeCreamDark = Color(0xFFF0E8DC)
|
||||
|
||||
val PantreeBrown = Color(0xFF5C3D2E)
|
||||
val PantreeBrownLight = Color(0xFF8B6355)
|
||||
|
||||
val PantreeRed = Color(0xFFD94F4F)
|
||||
val PantreeRedLight = Color(0xFFE87070)
|
||||
|
||||
val PantreeGray100 = Color(0xFFF5F5F5)
|
||||
val PantreeGray200 = Color(0xFFEEEEEE)
|
||||
val PantreeGray400 = Color(0xFFBDBDBD)
|
||||
val PantreeGray600 = Color(0xFF757575)
|
||||
val PantreeGray800 = Color(0xFF424242)
|
||||
val PantreeGray900 = Color(0xFF212121)
|
||||
|
||||
val White = Color(0xFFFFFFFF)
|
||||
val Black = Color(0xFF000000)
|
||||
|
||||
// Availability status colors
|
||||
val CanMakeGreen = Color(0xFF4CAF50)
|
||||
val PartialYellow = Color(0xFFFFC107)
|
||||
val MissingRed = Color(0xFFF44336)
|
||||
73
android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt
Normal file
73
android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,73 @@
|
||||
package com.pantree.app.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = PantreeGreen,
|
||||
onPrimary = White,
|
||||
primaryContainer = PantreeGreenLight,
|
||||
onPrimaryContainer = PantreeGreenDark,
|
||||
secondary = PantreeOrange,
|
||||
onSecondary = White,
|
||||
secondaryContainer = PantreeOrangeLight,
|
||||
onSecondaryContainer = PantreeOrangeDark,
|
||||
tertiary = PantreeBrown,
|
||||
onTertiary = White,
|
||||
background = PantreeCream,
|
||||
onBackground = PantreeGray900,
|
||||
surface = White,
|
||||
onSurface = PantreeGray900,
|
||||
surfaceVariant = PantreeCreamDark,
|
||||
onSurfaceVariant = PantreeGray600,
|
||||
error = PantreeRed,
|
||||
onError = White,
|
||||
outline = PantreeGray400
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = PantreeGreenLight,
|
||||
onPrimary = PantreeGreenDark,
|
||||
primaryContainer = PantreeGreenDark,
|
||||
onPrimaryContainer = PantreeGreenLight,
|
||||
secondary = PantreeOrangeLight,
|
||||
onSecondary = PantreeOrangeDark,
|
||||
background = PantreeGray900,
|
||||
onBackground = PantreeCream,
|
||||
surface = PantreeGray800,
|
||||
onSurface = PantreeCream,
|
||||
surfaceVariant = PantreeGray800,
|
||||
onSurfaceVariant = PantreeGray400,
|
||||
error = PantreeRedLight,
|
||||
onError = PantreeGray900,
|
||||
outline = PantreeGray600
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PantreeTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = PantreeTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
93
android/app/src/main/java/com/pantree/app/ui/theme/Type.kt
Normal file
93
android/app/src/main/java/com/pantree/app/ui/theme/Type.kt
Normal file
@@ -0,0 +1,93 @@
|
||||
package com.pantree.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val PantreeTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.5).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.pantree.app.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Observes network connectivity and exposes it as a Flow<Boolean>.
|
||||
* isOffline = true means no active internet connection.
|
||||
* Used to show the offline banner and disable write operations.
|
||||
*/
|
||||
@Singleton
|
||||
class ConnectivityObserver @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
val isOffline: Flow<Boolean> = callbackFlow {
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(false) // connected
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(true) // disconnected
|
||||
}
|
||||
|
||||
override fun onUnavailable() {
|
||||
trySend(true)
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
|
||||
// Emit initial state
|
||||
val isCurrentlyOffline = !isCurrentlyConnected()
|
||||
trySend(isCurrentlyOffline)
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
private fun isCurrentlyConnected(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.pantree.app.ui.auth
|
||||
|
||||
import com.pantree.app.data.model.AuthResponse
|
||||
import com.pantree.app.data.model.UserDto
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.repository.AuthRepository
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AuthViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var authRepository: AuthRepository
|
||||
private lateinit var viewModel: AuthViewModel
|
||||
|
||||
private val fakeUser = UserDto(
|
||||
id = "user-123",
|
||||
email = "feyre@nightcourt.com",
|
||||
name = "Feyre Archeron",
|
||||
profilePictureUrl = null,
|
||||
emailVerified = true,
|
||||
deletedAt = null,
|
||||
createdAt = "2024-01-15T10:30:00Z"
|
||||
)
|
||||
|
||||
private val fakeAuthResponse = AuthResponse(
|
||||
user = fakeUser,
|
||||
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
|
||||
expiresAt = "2024-01-16T10:30:00Z"
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
authRepository = mockk(relaxed = true)
|
||||
viewModel = AuthViewModel(authRepository)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
// ── Signup ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `signup success emits NavigateToHome`() = runTest {
|
||||
coEvery {
|
||||
authRepository.signup(any(), any(), any())
|
||||
} returns NetworkResult.Success(fakeAuthResponse)
|
||||
|
||||
val events = mutableListOf<AuthEvent>()
|
||||
val job = launch { viewModel.events.collect { events.add(it) } }
|
||||
|
||||
viewModel.signup("feyre@nightcourt.com", "password1", "Feyre Archeron")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(events.any { it is AuthEvent.NavigateToHome })
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
assertNull(viewModel.uiState.value.errorMessage)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signup with duplicate email shows friendly error`() = runTest {
|
||||
coEvery {
|
||||
authRepository.signup(any(), any(), any())
|
||||
} returns NetworkResult.Error(
|
||||
code = "CONFLICT",
|
||||
message = "Email already registered.",
|
||||
httpStatus = 409
|
||||
)
|
||||
|
||||
viewModel.signup("feyre@nightcourt.com", "password1", "Feyre Archeron")
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
assertTrue(state.errorMessage!!.contains("already exists"))
|
||||
}
|
||||
|
||||
// ── Signin ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `signin success emits NavigateToHome`() = runTest {
|
||||
coEvery {
|
||||
authRepository.signin(any(), any())
|
||||
} returns NetworkResult.Success(fakeAuthResponse)
|
||||
|
||||
val events = mutableListOf<AuthEvent>()
|
||||
val job = launch { viewModel.events.collect { events.add(it) } }
|
||||
|
||||
viewModel.signin("feyre@nightcourt.com", "password1")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(events.any { it is AuthEvent.NavigateToHome })
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signin with wrong credentials shows error`() = runTest {
|
||||
coEvery {
|
||||
authRepository.signin(any(), any())
|
||||
} returns NetworkResult.Error(
|
||||
code = "UNAUTHORIZED",
|
||||
message = "Invalid email or password.",
|
||||
httpStatus = 401
|
||||
)
|
||||
|
||||
viewModel.signin("feyre@nightcourt.com", "wrongpassword")
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
assertTrue(state.errorMessage!!.contains("don't match"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signin on pending-deletion account emits NavigateToRestore`() = runTest {
|
||||
coEvery {
|
||||
authRepository.signin(any(), any())
|
||||
} returns NetworkResult.Error(
|
||||
code = "ACCOUNT_PENDING_DELETION",
|
||||
message = "Account is pending deletion.",
|
||||
httpStatus = 403,
|
||||
extra = com.pantree.app.data.model.ApiError(
|
||||
error = "Account is pending deletion.",
|
||||
code = "ACCOUNT_PENDING_DELETION",
|
||||
timestamp = "2024-01-15T10:30:00Z",
|
||||
deletionScheduledAt = "2024-01-30T10:30:00Z",
|
||||
canRestore = true
|
||||
)
|
||||
)
|
||||
|
||||
val events = mutableListOf<AuthEvent>()
|
||||
val job = launch { viewModel.events.collect { events.add(it) } }
|
||||
|
||||
viewModel.signin("feyre@nightcourt.com", "password1")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(events.any { it is AuthEvent.NavigateToRestore })
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
// ── Password reset ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `requestPasswordReset always emits PasswordResetEmailSent on success`() = runTest {
|
||||
coEvery {
|
||||
authRepository.requestPasswordReset(any())
|
||||
} returns NetworkResult.Success(
|
||||
com.pantree.app.data.model.MessageResponse(
|
||||
message = "If an account exists...",
|
||||
timestamp = "2024-01-15T10:30:00Z"
|
||||
)
|
||||
)
|
||||
|
||||
val events = mutableListOf<AuthEvent>()
|
||||
val job = launch { viewModel.events.collect { events.add(it) } }
|
||||
|
||||
viewModel.requestPasswordReset("feyre@nightcourt.com")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(events.any { it is AuthEvent.PasswordResetEmailSent })
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
// ── clearError ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `clearError removes error message from state`() = runTest {
|
||||
coEvery {
|
||||
authRepository.signin(any(), any())
|
||||
} returns NetworkResult.Error("UNAUTHORIZED", "Invalid credentials.", 401)
|
||||
|
||||
viewModel.signin("x@x.com", "bad")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNotNull(viewModel.uiState.value.errorMessage)
|
||||
|
||||
viewModel.clearError()
|
||||
assertNull(viewModel.uiState.value.errorMessage)
|
||||
}
|
||||
|
||||
// ── isLoggedIn ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `isLoggedIn delegates to repository`() {
|
||||
every { authRepository.isLoggedIn() } returns true
|
||||
assertTrue(viewModel.isLoggedIn())
|
||||
|
||||
every { authRepository.isLoggedIn() } returns false
|
||||
assertFalse(viewModel.isLoggedIn())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.pantree.app.ui.pantry
|
||||
|
||||
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||
import com.pantree.app.data.model.PantryItemDto
|
||||
import com.pantree.app.data.model.PantryItemResponse
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.repository.PantryRepository
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PantryViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var pantryRepository: PantryRepository
|
||||
private lateinit var viewModel: PantryViewModel
|
||||
|
||||
private val fakeItems = listOf(
|
||||
PantryItemEntity("id-1", "Flour", 5, "2024-01-15T10:30:00Z", "2024-01-14T08:00:00Z"),
|
||||
PantryItemEntity("id-2", "Butter", 2, "2024-01-15T09:00:00Z", "2024-01-14T08:00:00Z")
|
||||
)
|
||||
|
||||
private val fakeItemDto = PantryItemDto(
|
||||
id = "id-3",
|
||||
itemName = "Eggs",
|
||||
quantity = 12,
|
||||
lastModified = "2024-01-15T11:00:00Z",
|
||||
createdAt = "2024-01-15T11:00:00Z"
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
pantryRepository = mockk(relaxed = true)
|
||||
every { pantryRepository.observePantryItems() } returns flowOf(fakeItems)
|
||||
coEvery { pantryRepository.refreshPantry() } returns NetworkResult.Success(
|
||||
com.pantree.app.data.model.PantryListResponse(fakeItems.map {
|
||||
PantryItemDto(it.id, it.itemName, it.quantity, it.lastModified, it.createdAt)
|
||||
}, "2024-01-15T10:30:05Z")
|
||||
)
|
||||
viewModel = PantryViewModel(pantryRepository)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state loads items from cache`() = runTest {
|
||||
advanceUntilIdle()
|
||||
assertEquals(fakeItems, viewModel.uiState.value.items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addItem success emits ItemAdded event`() = runTest {
|
||||
coEvery {
|
||||
pantryRepository.addItem("Eggs", 12)
|
||||
} returns NetworkResult.Success(PantryItemResponse(fakeItemDto))
|
||||
|
||||
val events = mutableListOf<PantryEvent>()
|
||||
val job = launch { viewModel.events.collect { events.add(it) } }
|
||||
|
||||
viewModel.addItem("Eggs", 12)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(events.any { it is PantryEvent.ItemAdded && it.itemName == "Eggs" })
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addItem duplicate sets duplicateConflict state`() = runTest {
|
||||
coEvery {
|
||||
pantryRepository.addItem("Flour", 3)
|
||||
} returns NetworkResult.Error(
|
||||
code = "DUPLICATE_ITEM",
|
||||
message = "'Flour' already exists in your pantry.",
|
||||
httpStatus = 409
|
||||
)
|
||||
|
||||
viewModel.addItem("Flour", 3)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.duplicateConflict)
|
||||
assertEquals("Flour", state.duplicateConflict!!.attemptedName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteItem success emits ItemDeleted event`() = runTest {
|
||||
val item = fakeItems[0]
|
||||
coEvery { pantryRepository.deleteItem(item.id) } returns NetworkResult.Success(Unit)
|
||||
|
||||
val events = mutableListOf<PantryEvent>()
|
||||
val job = launch { viewModel.events.collect { events.add(it) } }
|
||||
|
||||
viewModel.deleteItem(item)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(events.any { it is PantryEvent.ItemDeleted && it.itemName == item.itemName })
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh on no connection shows snackbar not error`() = runTest {
|
||||
coEvery { pantryRepository.refreshPantry() } returns NetworkResult.Error(
|
||||
code = "NO_CONNECTION",
|
||||
message = "No internet connection.",
|
||||
httpStatus = 0
|
||||
)
|
||||
|
||||
viewModel.refresh()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertNotNull(state.snackbarMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearDuplicateConflict removes conflict from state`() = runTest {
|
||||
coEvery {
|
||||
pantryRepository.addItem(any(), any())
|
||||
} returns NetworkResult.Error("DUPLICATE_ITEM", "Exists.", 409)
|
||||
|
||||
viewModel.addItem("Flour", 1)
|
||||
advanceUntilIdle()
|
||||
assertNotNull(viewModel.uiState.value.duplicateConflict)
|
||||
|
||||
viewModel.clearDuplicateConflict()
|
||||
assertNull(viewModel.uiState.value.duplicateConflict)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.pantree.app.ui.recipes
|
||||
|
||||
import com.pantree.app.data.local.entity.RecipeCacheEntity
|
||||
import com.pantree.app.data.model.*
|
||||
import com.pantree.app.data.remote.NetworkResult
|
||||
import com.pantree.app.data.repository.RecipeRepository
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RecipesViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var recipeRepository: RecipeRepository
|
||||
private lateinit var viewModel: RecipesViewModel
|
||||
|
||||
private val fakeRecipes = listOf(
|
||||
RecipeCacheEntity("r-1", "Pancakes", 4, 5, "can_make", 5, 5, "[]"),
|
||||
RecipeCacheEntity("r-2", "Chocolate Cake", 8, 9, "partial", 6, 9, "[\"cocoa\",\"vanilla\"]"),
|
||||
RecipeCacheEntity("r-3", "Omelette", 2, 3, "missing", 0, 3, "[\"eggs\",\"cheese\",\"butter\"]")
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
recipeRepository = mockk(relaxed = true)
|
||||
every { recipeRepository.observeRecipes() } returns flowOf(fakeRecipes)
|
||||
coEvery { recipeRepository.refreshRecipes(any()) } returns NetworkResult.Success(
|
||||
RecipeListResponse(emptyList(), "2024-01-15T10:30:05Z")
|
||||
)
|
||||
viewModel = RecipesViewModel(recipeRepository)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state shows all recipes`() = runTest {
|
||||
advanceUntilIdle()
|
||||
assertEquals(3, viewModel.uiState.value.filteredRecipes.size)
|
||||
assertEquals(RecipeFilter.ALL, viewModel.uiState.value.selectedFilter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filter CAN_MAKE shows only can_make recipes`() = runTest {
|
||||
advanceUntilIdle()
|
||||
viewModel.setFilter(RecipeFilter.CAN_MAKE)
|
||||
|
||||
val filtered = viewModel.uiState.value.filteredRecipes
|
||||
assertEquals(1, filtered.size)
|
||||
assertEquals("Pancakes", filtered[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filter PARTIAL shows only partial recipes`() = runTest {
|
||||
advanceUntilIdle()
|
||||
viewModel.setFilter(RecipeFilter.PARTIAL)
|
||||
|
||||
val filtered = viewModel.uiState.value.filteredRecipes
|
||||
assertEquals(1, filtered.size)
|
||||
assertEquals("Chocolate Cake", filtered[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search filters by name case-insensitively`() = runTest {
|
||||
advanceUntilIdle()
|
||||
viewModel.setSearchQuery("pan")
|
||||
|
||||
val filtered = viewModel.uiState.value.filteredRecipes
|
||||
assertEquals(1, filtered.size)
|
||||
assertEquals("Pancakes", filtered[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search with no results returns empty list`() = runTest {
|
||||
advanceUntilIdle()
|
||||
viewModel.setSearchQuery("xyzzy")
|
||||
|
||||
assertTrue(viewModel.uiState.value.filteredRecipes.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRecipeDetail success sets selectedRecipe`() = runTest {
|
||||
val fakeDetail = RecipeDetailDto(
|
||||
id = "r-1",
|
||||
name = "Pancakes",
|
||||
servings = 4,
|
||||
scaledServings = 4,
|
||||
instructions = "Mix and cook.",
|
||||
ingredients = listOf(
|
||||
RecipeIngredientDto("i-1", "flour", 2.0, "cups", true),
|
||||
RecipeIngredientDto("i-2", "milk", 1.5, "cups", true),
|
||||
RecipeIngredientDto("i-3", "eggs", 2.0, "whole", false)
|
||||
),
|
||||
availability = AvailabilitySummaryDto("partial", 2, 3)
|
||||
)
|
||||
|
||||
coEvery {
|
||||
recipeRepository.getRecipeDetail("r-1", null)
|
||||
} returns NetworkResult.Success(RecipeDetailResponse(fakeDetail))
|
||||
|
||||
viewModel.loadRecipeDetail("r-1")
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isLoadingDetail)
|
||||
assertNotNull(state.selectedRecipe)
|
||||
assertEquals("Pancakes", state.selectedRecipe!!.name)
|
||||
assertNull(state.detailError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRecipeDetail 404 sets friendly detailError`() = runTest {
|
||||
coEvery {
|
||||
recipeRepository.getRecipeDetail("bad-id", null)
|
||||
} returns NetworkResult.Error("NOT_FOUND", "Recipe not found.", 404)
|
||||
|
||||
viewModel.loadRecipeDetail("bad-id")
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isLoadingDetail)
|
||||
assertNull(state.selectedRecipe)
|
||||
assertNotNull(state.detailError)
|
||||
assertTrue(state.detailError!!.contains("couldn't be found"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearDetail resets selectedRecipe and scale`() = runTest {
|
||||
viewModel.clearDetail()
|
||||
val state = viewModel.uiState.value
|
||||
assertNull(state.selectedRecipe)
|
||||
assertNull(state.detailError)
|
||||
assertEquals(1, state.selectedScale)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user