From 75db2ea8f574decf6f1cde05418d78d9cd5a67d7 Mon Sep 17 00:00:00 2001 From: Azriel Date: Sun, 10 May 2026 15:09:12 +0000 Subject: [PATCH] feat(android): implement full Android frontend - Build config: build.gradle (app + root), settings.gradle, AndroidManifest - Application entry: PantreeApplication (Hilt), MainActivity (Compose host) - Data layer: - ApiService (Retrofit interface, all 20 endpoints) - ApiModels (all request/response DTOs) - AuthInterceptor (JWT Bearer header injection) - TokenStore (EncryptedSharedPreferences) - SyncPreferences (DataStore, last-sync timestamp) - PantreeDatabase (Room, 5 entities) - Entities: PantryItemEntity, RecipeEntity, RecipeIngredientEntity, ShoppingListEntity, ShoppingListItemEntity - DAOs: PantryDao, RecipeDao, ShoppingListDao - Repositories: AuthRepository, PantryRepository, RecipeRepository, ShoppingListRepository, SyncRepository - DI: AppModule (Hilt, provides OkHttp/Retrofit/Room/DAOs) - Util: Result sealed class, safeApiCall, toUserMessage (human-readable error mapping) - Sync: SyncManager (DefaultLifecycleObserver, triggers on app open/foreground) - Theme: Color, Type, Theme (Material 3, light + dark) - Navigation: Screen sealed class, PantreeNavGraph (deep links for password reset) - Shared components: LoadingState, ErrorState, EmptyState, OfflineBanner, PantreeTopBar - Auth screens: SplashScreen, SignInScreen, SignUpScreen, ForgotPasswordScreen, ResetPasswordScreen + AuthViewModel - Pantry screen: PantryScreen (list/add/edit/delete dialogs) + PantryViewModel - Recipe screens: RecipesScreen (filter chips, search, availability badges) + RecipeDetailScreen (scale 1x/2x/3x, ingredient pantry status) + RecipeViewModel - Shopping screens: ShoppingListsScreen + ShoppingListDetailScreen (check-off, progress bar, merge feedback, add-from-recipes) + ShoppingListViewModel - Settings screen: sign out + account deletion (15-day window copy) + SettingsViewModel - Tests: ResultTest (unit), RecipeDetailScreenTest (unit), PantryScreenTest (instrumented) - README: architecture, module structure, UI states table, sync strategy, setup Every screen handles loading / error / empty / success states. Offline: cached Room data shown read-only. --- android/README.md | 76 +++++ android/app/build.gradle | 128 ++++++++ .../app/ui/screens/pantry/PantryScreenTest.kt | 43 +++ android/app/src/main/AndroidManifest.xml | 39 +++ .../main/java/com/pantree/app/MainActivity.kt | 27 ++ .../com/pantree/app/PantreeApplication.kt | 7 + .../pantree/app/data/local/PantreeDatabase.kt | 29 ++ .../pantree/app/data/local/SyncPreferences.kt | 32 ++ .../com/pantree/app/data/local/TokenStore.kt | 50 +++ .../pantree/app/data/local/dao/PantryDao.kt | 29 ++ .../pantree/app/data/local/dao/RecipeDao.kt | 30 ++ .../app/data/local/dao/ShoppingListDao.kt | 39 +++ .../pantree/app/data/local/entity/Entities.kt | 77 +++++ .../com/pantree/app/data/model/ApiModels.kt | 251 ++++++++++++++ .../com/pantree/app/data/remote/ApiService.kt | 108 +++++++ .../app/data/remote/AuthInterceptor.kt | 22 ++ .../app/data/repository/AuthRepository.kt | 64 ++++ .../app/data/repository/PantryRepository.kt | 63 ++++ .../app/data/repository/RecipeRepository.kt | 50 +++ .../data/repository/ShoppingListRepository.kt | 93 ++++++ .../app/data/repository/SyncRepository.kt | 65 ++++ .../main/java/com/pantree/app/di/AppModule.kt | 75 +++++ .../java/com/pantree/app/sync/SyncManager.kt | 41 +++ .../app/ui/components/CommonComponents.kt | 147 +++++++++ .../app/ui/navigation/PantreeNavGraph.kt | 120 +++++++ .../com/pantree/app/ui/navigation/Screen.kt | 24 ++ .../app/ui/screens/auth/AuthViewModel.kt | 93 ++++++ .../ui/screens/auth/ForgotPasswordScreen.kt | 153 +++++++++ .../app/ui/screens/auth/SignInScreen.kt | 133 ++++++++ .../app/ui/screens/auth/SignUpScreen.kt | 124 +++++++ .../app/ui/screens/auth/SplashScreen.kt | 31 ++ .../app/ui/screens/pantry/PantryScreen.kt | 253 +++++++++++++++ .../app/ui/screens/pantry/PantryViewModel.kt | 84 +++++ .../ui/screens/recipe/RecipeDetailScreen.kt | 187 +++++++++++ .../app/ui/screens/recipe/RecipeViewModel.kt | 83 +++++ .../app/ui/screens/recipe/RecipesScreen.kt | 168 ++++++++++ .../app/ui/screens/settings/SettingsScreen.kt | 132 ++++++++ .../ui/screens/settings/SettingsViewModel.kt | 51 +++ .../shopping/ShoppingListDetailScreen.kt | 305 ++++++++++++++++++ .../screens/shopping/ShoppingListViewModel.kt | 148 +++++++++ .../screens/shopping/ShoppingListsScreen.kt | 175 ++++++++++ .../java/com/pantree/app/ui/theme/Color.kt | 24 ++ .../java/com/pantree/app/ui/theme/Theme.kt | 65 ++++ .../java/com/pantree/app/ui/theme/Type.kt | 54 ++++ .../main/java/com/pantree/app/util/Result.kt | 58 ++++ android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 4 + .../screens/recipe/RecipeDetailScreenTest.kt | 25 ++ .../java/com/pantree/app/util/ResultTest.kt | 31 ++ android/build.gradle | 8 + android/settings.gradle | 16 + 51 files changed, 4138 insertions(+) create mode 100644 android/README.md create mode 100644 android/app/build.gradle create mode 100644 android/app/src/androidTest/java/com/pantree/app/ui/screens/pantry/PantryScreenTest.kt create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/pantree/app/MainActivity.kt create mode 100644 android/app/src/main/java/com/pantree/app/PantreeApplication.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/local/PantreeDatabase.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/local/SyncPreferences.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/local/TokenStore.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/local/dao/PantryDao.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/local/dao/RecipeDao.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/local/dao/ShoppingListDao.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/local/entity/Entities.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/model/ApiModels.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/remote/ApiService.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/remote/AuthInterceptor.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/repository/AuthRepository.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/repository/PantryRepository.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/repository/RecipeRepository.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/repository/ShoppingListRepository.kt create mode 100644 android/app/src/main/java/com/pantree/app/data/repository/SyncRepository.kt create mode 100644 android/app/src/main/java/com/pantree/app/di/AppModule.kt create mode 100644 android/app/src/main/java/com/pantree/app/sync/SyncManager.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/components/CommonComponents.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/navigation/PantreeNavGraph.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/navigation/Screen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/auth/AuthViewModel.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/auth/ForgotPasswordScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/auth/SignInScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/auth/SignUpScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/auth/SplashScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/pantry/PantryScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/pantry/PantryViewModel.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipeDetailScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipeViewModel.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipesScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/settings/SettingsScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/settings/SettingsViewModel.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListDetailScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListViewModel.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListsScreen.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/theme/Color.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/com/pantree/app/ui/theme/Type.kt create mode 100644 android/app/src/main/java/com/pantree/app/util/Result.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/test/java/com/pantree/app/ui/screens/recipe/RecipeDetailScreenTest.kt create mode 100644 android/app/src/test/java/com/pantree/app/util/ResultTest.kt create mode 100644 android/build.gradle create mode 100644 android/settings.gradle diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..940e090 --- /dev/null +++ b/android/README.md @@ -0,0 +1,76 @@ +# Pantree Android + +Android client for Pantree — the app that tells you what you can cook with what you already have. + +## Architecture + +- **MVVM** — ViewModel + StateFlow, no LiveData +- **Hilt** — dependency injection +- **Room** — local cache (pantry items, recipes, shopping lists) +- **Retrofit + OkHttp** — network layer with JWT interceptor +- **Jetpack Compose** — declarative UI, Material 3 +- **EncryptedSharedPreferences** — JWT stored at rest +- **DataStore** — last-sync timestamp + +## Module Structure + +``` +app/src/main/java/com/pantree/app/ +├── data/ +│ ├── local/ # Room DB, DAOs, entities, TokenStore, SyncPreferences +│ ├── model/ # API DTOs (request/response) +│ ├── remote/ # ApiService (Retrofit), AuthInterceptor +│ └── repository/ # AuthRepository, PantryRepository, RecipeRepository, +│ # ShoppingListRepository, SyncRepository +├── di/ # Hilt AppModule +├── sync/ # SyncManager (lifecycle observer) +├── ui/ +│ ├── components/ # LoadingState, ErrorState, EmptyState, OfflineBanner, PantreeTopBar +│ ├── navigation/ # Screen sealed class, PantreeNavGraph +│ ├── screens/ +│ │ ├── auth/ # SplashScreen, SignInScreen, SignUpScreen, +│ │ │ # ForgotPasswordScreen, ResetPasswordScreen + AuthViewModel +│ │ ├── pantry/ # PantryScreen + PantryViewModel +│ │ ├── recipe/ # RecipesScreen, RecipeDetailScreen + RecipeViewModel +│ │ ├── shopping/ # ShoppingListsScreen, ShoppingListDetailScreen + ShoppingListViewModel +│ │ └── settings/ # SettingsScreen + SettingsViewModel +│ └── theme/ # Color, Type, Theme +└── util/ # Result, safeApiCall, toUserMessage +``` + +## UI States + +Every screen handles all four states: + +| State | Implementation | +|-------|---------------| +| **Loading** | `LoadingState` composable — spinner + contextual message | +| **Error** | `ErrorState` composable — emoji + message + retry button | +| **Empty** | `EmptyState` composable — emoji + title + subtitle + optional CTA | +| **Success** | Full content with `LazyColumn` / detail view | + +Offline: cached data shown read-only, `OfflineBanner` displayed. + +## Sync Strategy + +- `SyncManager` implements `DefaultLifecycleObserver` — triggers on `onStart` (app open + foreground) +- Delta sync via `GET /sync?since=` +- Server timestamp stored in DataStore, used as `since` on next sync +- Room cache updated; UI observes `Flow>` — updates automatically + +## Setup + +1. Set `BASE_URL` in `app/build.gradle` debug/release buildConfigFields +2. Set `GOOGLE_WEB_CLIENT_ID` for Google Sign-In +3. Run backend (`npm run dev` from repo root) +4. `./gradlew assembleDebug` + +## Running Tests + +```bash +# Unit tests +./gradlew test + +# Instrumented tests (requires emulator/device) +./gradlew connectedAndroidTest +``` diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..aaf7257 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,128 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'com.google.dagger.hilt.android' + id 'com.google.devtools.ksp' +} + +android { + namespace 'com.pantree.app' + compileSdk 34 + + defaultConfig { + applicationId "com.pantree.app" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary true + } + + buildConfigField "String", "BASE_URL", '"https://api.pantree.app/v1/"' + buildConfigField "String", "GOOGLE_WEB_CLIENT_ID", '"YOUR_GOOGLE_WEB_CLIENT_ID"' + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + buildConfigField "String", "BASE_URL", '"http://10.0.2.2:3000/v1/"' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + compose true + buildConfig true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.11' + } + + packaging { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +dependencies { + // Core + implementation 'androidx.core:core-ktx:1.13.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'androidx.activity:activity-compose:1.9.0' + + // Compose BOM + implementation platform('androidx.compose:compose-bom:2024.04.01') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + + // Navigation + implementation 'androidx.navigation:navigation-compose:2.7.7' + + // Hilt DI + implementation 'com.google.dagger:hilt-android:2.51.1' + ksp 'com.google.dagger:hilt-android-compiler:2.51.1' + implementation 'androidx.hilt:hilt-navigation-compose:1.2.0' + + // Room + implementation 'androidx.room:room-runtime:2.6.1' + implementation 'androidx.room:room-ktx:2.6.1' + ksp 'androidx.room:room-compiler:2.6.1' + + // Retrofit + OkHttp + implementation 'com.squareup.retrofit2:retrofit:2.11.0' + implementation 'com.squareup.retrofit2:converter-gson:2.11.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + + // Gson + implementation 'com.google.code.gson:gson:2.10.1' + + // ViewModel + StateFlow + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0' + + // EncryptedSharedPreferences + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + + // Google Identity (One Tap) + implementation 'com.google.android.gms:play-services-auth:21.1.1' + + // Coil (image loading) + implementation 'io.coil-kt:coil-compose:2.6.0' + + // DataStore + implementation 'androidx.datastore:datastore-preferences:1.1.0' + + // Coroutines + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0' + + // Testing + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0' + testImplementation 'io.mockk:mockk:1.13.10' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation platform('androidx.compose:compose-bom:2024.04.01') + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' +} diff --git a/android/app/src/androidTest/java/com/pantree/app/ui/screens/pantry/PantryScreenTest.kt b/android/app/src/androidTest/java/com/pantree/app/ui/screens/pantry/PantryScreenTest.kt new file mode 100644 index 0000000..5cdb89f --- /dev/null +++ b/android/app/src/androidTest/java/com/pantree/app/ui/screens/pantry/PantryScreenTest.kt @@ -0,0 +1,43 @@ +package com.pantree.app.ui.screens.pantry + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.pantree.app.data.local.entity.PantryItemEntity +import com.pantree.app.ui.theme.PantreeTheme +import org.junit.Rule +import org.junit.Test + +class PantryScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun emptyState_showsEmptyMessage() { + composeTestRule.setContent { + PantreeTheme { + EmptyState( + emoji = "\uD83E\uDED9", + title = "Your pantry is empty", + subtitle = "Add ingredients you have on hand." + ) + } + } + composeTestRule.onNodeWithText("Your pantry is empty").assertIsDisplayed() + } + + @Test + fun pantryItemCard_displaysNameAndQuantity() { + val item = PantryItemEntity( + id = "1", itemName = "Flour", quantity = 3, + lastModified = "2024-01-01T00:00:00Z", createdAt = "2024-01-01T00:00:00Z" + ) + composeTestRule.setContent { + PantreeTheme { + PantryItemCard(item = item, onEdit = {}, onDelete = {}) + } + } + composeTestRule.onNodeWithText("Flour").assertIsDisplayed() + composeTestRule.onNodeWithText("Qty: 3").assertIsDisplayed() + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..81392fb --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/pantree/app/MainActivity.kt b/android/app/src/main/java/com/pantree/app/MainActivity.kt new file mode 100644 index 0000000..63cdc48 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/MainActivity.kt @@ -0,0 +1,27 @@ +package com.pantree.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.pantree.app.ui.navigation.PantreeNavGraph +import com.pantree.app.ui.theme.PantreeTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + PantreeTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PantreeNavGraph() + } + } + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/PantreeApplication.kt b/android/app/src/main/java/com/pantree/app/PantreeApplication.kt new file mode 100644 index 0000000..8e54987 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/PantreeApplication.kt @@ -0,0 +1,7 @@ +package com.pantree.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class PantreeApplication : Application() diff --git a/android/app/src/main/java/com/pantree/app/data/local/PantreeDatabase.kt b/android/app/src/main/java/com/pantree/app/data/local/PantreeDatabase.kt new file mode 100644 index 0000000..a8dca17 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/local/PantreeDatabase.kt @@ -0,0 +1,29 @@ +package com.pantree.app.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.pantree.app.data.local.dao.PantryDao +import com.pantree.app.data.local.dao.RecipeDao +import com.pantree.app.data.local.dao.ShoppingListDao +import com.pantree.app.data.local.entity.PantryItemEntity +import com.pantree.app.data.local.entity.RecipeEntity +import com.pantree.app.data.local.entity.RecipeIngredientEntity +import com.pantree.app.data.local.entity.ShoppingListEntity +import com.pantree.app.data.local.entity.ShoppingListItemEntity + +@Database( + entities = [ + PantryItemEntity::class, + RecipeEntity::class, + RecipeIngredientEntity::class, + ShoppingListEntity::class, + ShoppingListItemEntity::class + ], + version = 1, + exportSchema = false +) +abstract class PantreeDatabase : RoomDatabase() { + abstract fun pantryDao(): PantryDao + abstract fun recipeDao(): RecipeDao + abstract fun shoppingListDao(): ShoppingListDao +} diff --git a/android/app/src/main/java/com/pantree/app/data/local/SyncPreferences.kt b/android/app/src/main/java/com/pantree/app/data/local/SyncPreferences.kt new file mode 100644 index 0000000..4905137 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/local/SyncPreferences.kt @@ -0,0 +1,32 @@ +package com.pantree.app.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +val Context.dataStore: DataStore by preferencesDataStore(name = "sync_prefs") + +@Singleton +class SyncPreferences @Inject constructor( + @ApplicationContext private val context: Context +) { + private val LAST_SYNC_KEY = stringPreferencesKey("last_sync_timestamp") + + val lastSyncTimestamp: Flow = context.dataStore.data.map { prefs -> + prefs[LAST_SYNC_KEY] ?: "1970-01-01T00:00:00.000Z" + } + + suspend fun updateLastSync(timestamp: String) { + context.dataStore.edit { prefs -> + prefs[LAST_SYNC_KEY] = timestamp + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/data/local/TokenStore.kt b/android/app/src/main/java/com/pantree/app/data/local/TokenStore.kt new file mode 100644 index 0000000..c436765 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/local/TokenStore.kt @@ -0,0 +1,50 @@ +package com.pantree.app.data.local + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenStore @Inject constructor( + @ApplicationContext private val context: Context +) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs = EncryptedSharedPreferences.create( + context, + "pantree_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun saveToken(token: String, expiresAt: String) { + prefs.edit() + .putString(KEY_TOKEN, token) + .putString(KEY_EXPIRES_AT, expiresAt) + .apply() + } + + fun getToken(): String? = prefs.getString(KEY_TOKEN, null) + + fun getExpiresAt(): String? = prefs.getString(KEY_EXPIRES_AT, null) + + fun clearToken() { + prefs.edit() + .remove(KEY_TOKEN) + .remove(KEY_EXPIRES_AT) + .apply() + } + + fun isLoggedIn(): Boolean = getToken() != null + + companion object { + private const val KEY_TOKEN = "jwt_token" + private const val KEY_EXPIRES_AT = "jwt_expires_at" + } +} diff --git a/android/app/src/main/java/com/pantree/app/data/local/dao/PantryDao.kt b/android/app/src/main/java/com/pantree/app/data/local/dao/PantryDao.kt new file mode 100644 index 0000000..4d11152 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/local/dao/PantryDao.kt @@ -0,0 +1,29 @@ +package com.pantree.app.data.local.dao + +import androidx.room.* +import com.pantree.app.data.local.entity.PantryItemEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface PantryDao { + @Query("SELECT * FROM pantry_items ORDER BY item_name ASC") + fun getAllItems(): Flow> + + @Query("SELECT * FROM pantry_items WHERE id = :id") + suspend fun getItemById(id: String): PantryItemEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(item: PantryItemEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Update + suspend fun updateItem(item: PantryItemEntity) + + @Query("DELETE FROM pantry_items WHERE id = :id") + suspend fun deleteItemById(id: String) + + @Query("DELETE FROM pantry_items") + suspend fun deleteAll() +} diff --git a/android/app/src/main/java/com/pantree/app/data/local/dao/RecipeDao.kt b/android/app/src/main/java/com/pantree/app/data/local/dao/RecipeDao.kt new file mode 100644 index 0000000..d317679 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/local/dao/RecipeDao.kt @@ -0,0 +1,30 @@ +package com.pantree.app.data.local.dao + +import androidx.room.* +import com.pantree.app.data.local.entity.RecipeEntity +import com.pantree.app.data.local.entity.RecipeIngredientEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface RecipeDao { + @Query("SELECT * FROM recipes ORDER BY name ASC") + fun getAllRecipes(): Flow> + + @Query("SELECT * FROM recipes WHERE can_make = 1 ORDER BY name ASC") + fun getCanMakeRecipes(): Flow> + + @Query("SELECT * FROM recipes WHERE can_make = 0 AND available_ingredient_count > 0 ORDER BY availability_percentage DESC") + fun getPartialRecipes(): Flow> + + @Query("SELECT * FROM recipe_ingredients WHERE recipe_id = :recipeId") + suspend fun getIngredientsForRecipe(recipeId: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRecipes(recipes: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertIngredients(ingredients: List) + + @Query("DELETE FROM recipes") + suspend fun deleteAll() +} diff --git a/android/app/src/main/java/com/pantree/app/data/local/dao/ShoppingListDao.kt b/android/app/src/main/java/com/pantree/app/data/local/dao/ShoppingListDao.kt new file mode 100644 index 0000000..2da878c --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/local/dao/ShoppingListDao.kt @@ -0,0 +1,39 @@ +package com.pantree.app.data.local.dao + +import androidx.room.* +import com.pantree.app.data.local.entity.ShoppingListEntity +import com.pantree.app.data.local.entity.ShoppingListItemEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ShoppingListDao { + @Query("SELECT * FROM shopping_lists ORDER BY last_modified DESC") + fun getAllLists(): Flow> + + @Query("SELECT * FROM shopping_list_items WHERE shopping_list_id = :listId ORDER BY item_name ASC") + fun getItemsForList(listId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertList(list: ShoppingListEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLists(lists: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(item: ShoppingListItemEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Query("DELETE FROM shopping_lists WHERE id = :id") + suspend fun deleteListById(id: String) + + @Query("DELETE FROM shopping_list_items WHERE id = :id") + suspend fun deleteItemById(id: String) + + @Query("DELETE FROM shopping_list_items WHERE shopping_list_id = :listId") + suspend fun deleteItemsForList(listId: String) + + @Query("DELETE FROM shopping_lists") + suspend fun deleteAll() +} diff --git a/android/app/src/main/java/com/pantree/app/data/local/entity/Entities.kt b/android/app/src/main/java/com/pantree/app/data/local/entity/Entities.kt new file mode 100644 index 0000000..3d9ebc4 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/local/entity/Entities.kt @@ -0,0 +1,77 @@ +package com.pantree.app.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "pantry_items") +data class PantryItemEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "item_name") val itemName: String, + val quantity: Int, + @ColumnInfo(name = "last_modified") val lastModified: String, + @ColumnInfo(name = "created_at") val createdAt: String +) + +@Entity(tableName = "recipes") +data class RecipeEntity( + @PrimaryKey val id: String, + val name: String, + val servings: Int, + @ColumnInfo(name = "ingredient_count") val ingredientCount: Int, + @ColumnInfo(name = "available_ingredient_count") val availableIngredientCount: Int, + @ColumnInfo(name = "can_make") val canMake: Boolean, + @ColumnInfo(name = "availability_percentage") val availabilityPercentage: Double +) + +@Entity( + tableName = "recipe_ingredients", + foreignKeys = [ForeignKey( + entity = RecipeEntity::class, + parentColumns = ["id"], + childColumns = ["recipe_id"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index("recipe_id")] +) +data class RecipeIngredientEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "recipe_id") val recipeId: String, + @ColumnInfo(name = "item_name") val itemName: String, + val quantity: Double, + @ColumnInfo(name = "original_quantity") val originalQuantity: Double, + val unit: String, + @ColumnInfo(name = "in_pantry") val inPantry: Boolean +) + +@Entity(tableName = "shopping_lists") +data class ShoppingListEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "list_name") val listName: String, + @ColumnInfo(name = "item_count") val itemCount: Int, + @ColumnInfo(name = "checked_count") val checkedCount: Int, + @ColumnInfo(name = "last_modified") val lastModified: String, + @ColumnInfo(name = "created_at") val createdAt: String +) + +@Entity( + tableName = "shopping_list_items", + foreignKeys = [ForeignKey( + entity = ShoppingListEntity::class, + parentColumns = ["id"], + childColumns = ["shopping_list_id"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index("shopping_list_id")] +) +data class ShoppingListItemEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "shopping_list_id") val shoppingListId: String, + @ColumnInfo(name = "item_name") val itemName: String, + val quantity: Double, + val unit: String, + @ColumnInfo(name = "checked_off") val checkedOff: Boolean, + @ColumnInfo(name = "last_modified") val lastModified: String +) diff --git a/android/app/src/main/java/com/pantree/app/data/model/ApiModels.kt b/android/app/src/main/java/com/pantree/app/data/model/ApiModels.kt new file mode 100644 index 0000000..a083f03 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/model/ApiModels.kt @@ -0,0 +1,251 @@ +package com.pantree.app.data.model + +import com.google.gson.annotations.SerializedName + +// ── Auth ────────────────────────────────────────────────────────────────────── + +data class SignupRequest( + val email: String, + val password: String, + val name: String +) + +data class SigninRequest( + val email: String, + val password: String +) + +data class GoogleAuthRequest( + @SerializedName("id_token") val idToken: String +) + +data class PasswordResetRequest( + val email: String +) + +data class ConfirmPasswordResetRequest( + val token: String, + @SerializedName("new_password") val newPassword: String +) + +data class AuthResponse( + val user: UserDto, + val token: String, + @SerializedName("expires_at") val expiresAt: String, + @SerializedName("is_new_user") val isNewUser: Boolean? = null +) + +data class UserDto( + val id: String, + val email: String, + val name: String, + @SerializedName("profile_picture_url") val profilePictureUrl: String?, + @SerializedName("deleted_at") val deletedAt: String?, + @SerializedName("created_at") val createdAt: String +) + +data class MessageResponse( + val message: String, + val timestamp: String +) + +data class RestoreAccountResponse( + val user: UserDto, + val message: String, + val timestamp: String +) + +data class ApiError( + val error: String, + val code: String, + val timestamp: String, + @SerializedName("deletion_scheduled_at") val deletionScheduledAt: String? = null +) + +// ── Pantry ──────────────────────────────────────────────────────────────────── + +data class AddPantryItemRequest( + @SerializedName("item_name") val itemName: String, + val quantity: Int +) + +data class UpdatePantryItemRequest( + val quantity: Int +) + +data class PantryItemDto( + val id: String, + @SerializedName("item_name") val itemName: String, + val quantity: Int, + @SerializedName("last_modified") val lastModified: String, + @SerializedName("created_at") val createdAt: String +) + +data class PantryListResponse( + val items: List, + @SerializedName("synced_at") val syncedAt: String +) + +data class PantryItemResponse( + val item: PantryItemDto, + @SerializedName("synced_at") val syncedAt: String +) + +// ── Recipes ─────────────────────────────────────────────────────────────────── + +data class RecipeSummaryDto( + val id: String, + val name: String, + val servings: Int, + @SerializedName("ingredient_count") val ingredientCount: Int, + @SerializedName("available_ingredient_count") val availableIngredientCount: Int, + @SerializedName("can_make") val canMake: Boolean, + @SerializedName("availability_percentage") val availabilityPercentage: Double +) + +data class RecipeIngredientDto( + val id: String, + @SerializedName("item_name") val itemName: String, + val quantity: Double, + @SerializedName("original_quantity") val originalQuantity: Double, + val unit: String, + @SerializedName("in_pantry") val inPantry: Boolean +) + +data class RecipeDetailDto( + val id: String, + val name: String, + val servings: Int, + @SerializedName("scaled_servings") val scaledServings: Int, + @SerializedName("scale_factor") val scaleFactor: Int, + val instructions: String, + val ingredients: List, + @SerializedName("can_make") val canMake: Boolean, + @SerializedName("available_ingredient_count") val availableIngredientCount: Int, + @SerializedName("ingredient_count") val ingredientCount: Int +) + +data class PaginationDto( + val page: Int, + val limit: Int, + val total: Int, + @SerializedName("total_pages") val totalPages: Int +) + +data class RecipeListResponse( + val recipes: List, + val pagination: PaginationDto, + @SerializedName("synced_at") val syncedAt: String +) + +data class RecipeDetailResponse( + val recipe: RecipeDetailDto +) + +// ── Shopping Lists ──────────────────────────────────────────────────────────── + +data class CreateShoppingListRequest( + @SerializedName("list_name") val listName: String +) + +data class AddShoppingListItemRequest( + @SerializedName("item_name") val itemName: String, + val quantity: Double, + val unit: String +) + +data class UpdateShoppingListItemRequest( + val quantity: Double? = null, + val unit: String? = null, + @SerializedName("checked_off") val checkedOff: Boolean? = null +) + +data class AddRecipesToListRequest( + @SerializedName("recipe_ids") val recipeIds: List, + @SerializedName("scale_factor") val scaleFactor: Int = 1 +) + +data class ShoppingListSummaryDto( + val id: String, + @SerializedName("list_name") val listName: String, + @SerializedName("item_count") val itemCount: Int, + @SerializedName("checked_count") val checkedCount: Int, + @SerializedName("last_modified") val lastModified: String, + @SerializedName("created_at") val createdAt: String +) + +data class ShoppingListItemDto( + val id: String, + @SerializedName("item_name") val itemName: String, + val quantity: Double, + val unit: String, + @SerializedName("checked_off") val checkedOff: Boolean, + @SerializedName("last_modified") val lastModified: String, + val merged: Boolean? = null +) + +data class ShoppingListDetailDto( + val id: String, + @SerializedName("list_name") val listName: String, + @SerializedName("last_modified") val lastModified: String, + @SerializedName("created_at") val createdAt: String, + val items: List +) + +data class ShoppingListsResponse( + @SerializedName("shopping_lists") val shoppingLists: List, + @SerializedName("synced_at") val syncedAt: String +) + +data class ShoppingListResponse( + @SerializedName("shopping_list") val shoppingList: ShoppingListSummaryDto +) + +data class ShoppingListDetailResponse( + @SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto, + @SerializedName("synced_at") val syncedAt: String +) + +data class ShoppingListItemResponse( + val item: ShoppingListItemDto, + @SerializedName("synced_at") val syncedAt: String? = null, + val merged: Boolean? = null, + @SerializedName("previous_quantity") val previousQuantity: Double? = null +) + +data class AddRecipesResponse( + @SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto, + @SerializedName("recipes_added") val recipesAdded: Int, + @SerializedName("items_merged") val itemsMerged: Int, + @SerializedName("items_created") val itemsCreated: Int +) + +// ── Sync ────────────────────────────────────────────────────────────────────── + +data class SyncPantryDto( + val updated: List, + val deleted: List +) + +data class SyncListItemsDto( + val updated: List, + val deleted: List +) + +data class SyncShoppingListDto( + val id: String, + @SerializedName("list_name") val listName: String, + @SerializedName("last_modified") val lastModified: String, + val items: SyncListItemsDto +) + +data class SyncShoppingListsDto( + val updated: List, + val deleted: List +) + +data class SyncResponse( + @SerializedName("server_timestamp") val serverTimestamp: String, + val pantry: SyncPantryDto, + @SerializedName("shopping_lists") val shoppingLists: SyncShoppingListsDto +) diff --git a/android/app/src/main/java/com/pantree/app/data/remote/ApiService.kt b/android/app/src/main/java/com/pantree/app/data/remote/ApiService.kt new file mode 100644 index 0000000..27a655a --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/remote/ApiService.kt @@ -0,0 +1,108 @@ +package com.pantree.app.data.remote + +import com.pantree.app.data.model.* +import retrofit2.Response +import retrofit2.http.* + +interface ApiService { + + // ── Auth ────────────────────────────────────────────────────────────────── + + @POST("auth/signup") + suspend fun signup(@Body request: SignupRequest): Response + + @POST("auth/signin") + suspend fun signin(@Body request: SigninRequest): Response + + @POST("auth/google") + suspend fun googleAuth(@Body request: GoogleAuthRequest): Response + + @POST("auth/password-reset") + suspend fun requestPasswordReset(@Body request: PasswordResetRequest): Response + + @PUT("auth/password-reset") + suspend fun confirmPasswordReset(@Body request: ConfirmPasswordResetRequest): Response + + @DELETE("auth/account") + suspend fun deleteAccount(): Response + + @POST("auth/restore-account") + suspend fun restoreAccount(): Response + + // ── Pantry ──────────────────────────────────────────────────────────────── + + @GET("pantry") + suspend fun getPantryItems(): Response + + @POST("pantry") + suspend fun addPantryItem(@Body request: AddPantryItemRequest): Response + + @PUT("pantry/{itemId}") + suspend fun updatePantryItem( + @Path("itemId") itemId: String, + @Body request: UpdatePantryItemRequest + ): Response + + @DELETE("pantry/{itemId}") + suspend fun deletePantryItem(@Path("itemId") itemId: String): Response + + // ── Recipes ─────────────────────────────────────────────────────────────── + + @GET("recipes") + suspend fun getRecipes( + @Query("filter") filter: String = "all", + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 20, + @Query("search") search: String? = null + ): Response + + @GET("recipes/{recipeId}") + suspend fun getRecipeById( + @Path("recipeId") recipeId: String, + @Query("scale") scale: Int = 1 + ): Response + + // ── Shopping Lists ──────────────────────────────────────────────────────── + + @GET("shopping-lists") + suspend fun getShoppingLists(): Response + + @POST("shopping-lists") + suspend fun createShoppingList(@Body request: CreateShoppingListRequest): Response + + @GET("shopping-lists/{listId}") + suspend fun getShoppingListById(@Path("listId") listId: String): Response + + @DELETE("shopping-lists/{listId}") + suspend fun deleteShoppingList(@Path("listId") listId: String): Response + + @POST("shopping-lists/{listId}/items") + suspend fun addShoppingListItem( + @Path("listId") listId: String, + @Body request: AddShoppingListItemRequest + ): Response + + @POST("shopping-lists/{listId}/add-recipes") + suspend fun addRecipesToShoppingList( + @Path("listId") listId: String, + @Body request: AddRecipesToListRequest + ): Response + + @PUT("shopping-lists/{listId}/items/{itemId}") + suspend fun updateShoppingListItem( + @Path("listId") listId: String, + @Path("itemId") itemId: String, + @Body request: UpdateShoppingListItemRequest + ): Response + + @DELETE("shopping-lists/{listId}/items/{itemId}") + suspend fun deleteShoppingListItem( + @Path("listId") listId: String, + @Path("itemId") itemId: String + ): Response + + // ── Sync ────────────────────────────────────────────────────────────────── + + @GET("sync") + suspend fun sync(@Query("since") since: String): Response +} diff --git a/android/app/src/main/java/com/pantree/app/data/remote/AuthInterceptor.kt b/android/app/src/main/java/com/pantree/app/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..8e453f1 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/remote/AuthInterceptor.kt @@ -0,0 +1,22 @@ +package com.pantree.app.data.remote + +import com.pantree.app.data.local.TokenStore +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class AuthInterceptor @Inject constructor( + private val tokenStore: TokenStore +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = tokenStore.getToken() + val request = if (token != null) { + chain.request().newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } + return chain.proceed(request) + } +} diff --git a/android/app/src/main/java/com/pantree/app/data/repository/AuthRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/AuthRepository.kt new file mode 100644 index 0000000..d08a8ec --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/repository/AuthRepository.kt @@ -0,0 +1,64 @@ +package com.pantree.app.data.repository + +import com.google.gson.Gson +import com.pantree.app.data.model.ApiError +import com.pantree.app.data.model.AuthResponse +import com.pantree.app.data.model.ConfirmPasswordResetRequest +import com.pantree.app.data.model.GoogleAuthRequest +import com.pantree.app.data.model.MessageResponse +import com.pantree.app.data.model.PasswordResetRequest +import com.pantree.app.data.model.RestoreAccountResponse +import com.pantree.app.data.model.SigninRequest +import com.pantree.app.data.model.SignupRequest +import com.pantree.app.data.local.TokenStore +import com.pantree.app.data.remote.ApiService +import com.pantree.app.util.Result +import com.pantree.app.util.safeApiCall +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthRepository @Inject constructor( + private val api: ApiService, + private val tokenStore: TokenStore, + private val gson: Gson +) { + suspend fun signup(email: String, password: String, name: String): Result { + val result = safeApiCall(gson) { api.signup(SignupRequest(email, password, name)) } + if (result is Result.Success) { + tokenStore.saveToken(result.data.token, result.data.expiresAt) + } + return result + } + + suspend fun signin(email: String, password: String): Result { + val result = safeApiCall(gson) { api.signin(SigninRequest(email, password)) } + if (result is Result.Success) { + tokenStore.saveToken(result.data.token, result.data.expiresAt) + } + return result + } + + suspend fun googleAuth(idToken: String): Result { + val result = safeApiCall(gson) { api.googleAuth(GoogleAuthRequest(idToken)) } + if (result is Result.Success) { + tokenStore.saveToken(result.data.token, result.data.expiresAt) + } + return result + } + + suspend fun requestPasswordReset(email: String): Result = + safeApiCall(gson) { api.requestPasswordReset(PasswordResetRequest(email)) } + + suspend fun confirmPasswordReset(token: String, newPassword: String): Result = + safeApiCall(gson) { api.confirmPasswordReset(ConfirmPasswordResetRequest(token, newPassword)) } + + suspend fun deleteAccount(): Result = + safeApiCall(gson) { api.deleteAccount() } + + suspend fun restoreAccount(): Result = + safeApiCall(gson) { api.restoreAccount() } + + fun logout() = tokenStore.clearToken() + fun isLoggedIn() = tokenStore.isLoggedIn() +} diff --git a/android/app/src/main/java/com/pantree/app/data/repository/PantryRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/PantryRepository.kt new file mode 100644 index 0000000..9936a21 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/repository/PantryRepository.kt @@ -0,0 +1,63 @@ +package com.pantree.app.data.repository + +import com.google.gson.Gson +import com.pantree.app.data.local.dao.PantryDao +import com.pantree.app.data.local.entity.PantryItemEntity +import com.pantree.app.data.model.AddPantryItemRequest +import com.pantree.app.data.model.PantryItemDto +import com.pantree.app.data.model.PantryItemResponse +import com.pantree.app.data.model.PantryListResponse +import com.pantree.app.data.model.UpdatePantryItemRequest +import com.pantree.app.data.remote.ApiService +import com.pantree.app.util.Result +import com.pantree.app.util.safeApiCall +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PantryRepository @Inject constructor( + private val api: ApiService, + private val pantryDao: PantryDao, + private val gson: Gson +) { + fun getLocalItems(): Flow> = pantryDao.getAllItems() + + suspend fun fetchAndCacheItems(): Result { + val result = safeApiCall(gson) { api.getPantryItems() } + if (result is Result.Success) { + pantryDao.deleteAll() + pantryDao.insertItems(result.data.items.map { it.toEntity() }) + } + return result + } + + suspend fun addItem(name: String, quantity: Int): Result { + val result = safeApiCall(gson) { api.addPantryItem(AddPantryItemRequest(name, quantity)) } + if (result is Result.Success) { + pantryDao.insertItem(result.data.item.toEntity()) + } + return result + } + + suspend fun updateItem(id: String, quantity: Int): Result { + val result = safeApiCall(gson) { api.updatePantryItem(id, UpdatePantryItemRequest(quantity)) } + if (result is Result.Success) { + pantryDao.insertItem(result.data.item.toEntity()) + } + return result + } + + suspend fun deleteItem(id: String): Result { + val result = safeApiCall(gson) { api.deletePantryItem(id) } + if (result is Result.Success) { + pantryDao.deleteItemById(id) + } + return result + } + + private fun PantryItemDto.toEntity() = PantryItemEntity( + id = id, itemName = itemName, quantity = quantity, + lastModified = lastModified, createdAt = createdAt + ) +} diff --git a/android/app/src/main/java/com/pantree/app/data/repository/RecipeRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/RecipeRepository.kt new file mode 100644 index 0000000..a2f2238 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/repository/RecipeRepository.kt @@ -0,0 +1,50 @@ +package com.pantree.app.data.repository + +import com.google.gson.Gson +import com.pantree.app.data.local.dao.RecipeDao +import com.pantree.app.data.local.entity.RecipeEntity +import com.pantree.app.data.local.entity.RecipeIngredientEntity +import com.pantree.app.data.model.RecipeDetailResponse +import com.pantree.app.data.model.RecipeListResponse +import com.pantree.app.data.model.RecipeSummaryDto +import com.pantree.app.data.remote.ApiService +import com.pantree.app.util.Result +import com.pantree.app.util.safeApiCall +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RecipeRepository @Inject constructor( + private val api: ApiService, + private val recipeDao: RecipeDao, + private val gson: Gson +) { + fun getLocalRecipes(): Flow> = recipeDao.getAllRecipes() + fun getCanMakeRecipes(): Flow> = recipeDao.getCanMakeRecipes() + fun getPartialRecipes(): Flow> = recipeDao.getPartialRecipes() + + suspend fun fetchRecipes( + filter: String = "all", + page: Int = 1, + search: String? = null + ): Result { + val result = safeApiCall(gson) { api.getRecipes(filter, page, 20, search) } + if (result is Result.Success && page == 1) { + recipeDao.deleteAll() + recipeDao.insertRecipes(result.data.recipes.map { it.toEntity() }) + } + return result + } + + suspend fun getRecipeById(id: String, scale: Int = 1): Result = + safeApiCall(gson) { api.getRecipeById(id, scale) } + + private fun RecipeSummaryDto.toEntity() = RecipeEntity( + id = id, name = name, servings = servings, + ingredientCount = ingredientCount, + availableIngredientCount = availableIngredientCount, + canMake = canMake, + availabilityPercentage = availabilityPercentage + ) +} diff --git a/android/app/src/main/java/com/pantree/app/data/repository/ShoppingListRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/ShoppingListRepository.kt new file mode 100644 index 0000000..4b4771b --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/repository/ShoppingListRepository.kt @@ -0,0 +1,93 @@ +package com.pantree.app.data.repository + +import com.google.gson.Gson +import com.pantree.app.data.local.dao.ShoppingListDao +import com.pantree.app.data.local.entity.ShoppingListEntity +import com.pantree.app.data.local.entity.ShoppingListItemEntity +import com.pantree.app.data.model.* +import com.pantree.app.data.remote.ApiService +import com.pantree.app.util.Result +import com.pantree.app.util.safeApiCall +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ShoppingListRepository @Inject constructor( + private val api: ApiService, + private val shoppingListDao: ShoppingListDao, + private val gson: Gson +) { + fun getLocalLists(): Flow> = shoppingListDao.getAllLists() + fun getLocalItems(listId: String): Flow> = + shoppingListDao.getItemsForList(listId) + + suspend fun fetchLists(): Result { + val result = safeApiCall(gson) { api.getShoppingLists() } + if (result is Result.Success) { + shoppingListDao.deleteAll() + shoppingListDao.insertLists(result.data.shoppingLists.map { it.toEntity() }) + } + return result + } + + suspend fun createList(name: String): Result { + val result = safeApiCall(gson) { api.createShoppingList(CreateShoppingListRequest(name)) } + if (result is Result.Success) { + shoppingListDao.insertList(result.data.shoppingList.toEntity()) + } + return result + } + + suspend fun fetchListById(listId: String): Result { + val result = safeApiCall(gson) { api.getShoppingListById(listId) } + if (result is Result.Success) { + shoppingListDao.deleteItemsForList(listId) + shoppingListDao.insertItems(result.data.shoppingList.items.map { it.toEntity(listId) }) + } + return result + } + + suspend fun deleteList(listId: String): Result { + val result = safeApiCall(gson) { api.deleteShoppingList(listId) } + if (result is Result.Success) shoppingListDao.deleteListById(listId) + return result + } + + suspend fun addItem(listId: String, name: String, quantity: Double, unit: String): Result { + val result = safeApiCall(gson) { api.addShoppingListItem(listId, AddShoppingListItemRequest(name, quantity, unit)) } + if (result is Result.Success) shoppingListDao.insertItem(result.data.item.toEntity(listId)) + return result + } + + suspend fun addRecipesToList(listId: String, recipeIds: List, scaleFactor: Int): Result { + val result = safeApiCall(gson) { api.addRecipesToShoppingList(listId, AddRecipesToListRequest(recipeIds, scaleFactor)) } + if (result is Result.Success) { + shoppingListDao.deleteItemsForList(listId) + shoppingListDao.insertItems(result.data.shoppingList.items.map { it.toEntity(listId) }) + } + return result + } + + suspend fun updateItem(listId: String, itemId: String, quantity: Double? = null, unit: String? = null, checkedOff: Boolean? = null): Result { + val result = safeApiCall(gson) { api.updateShoppingListItem(listId, itemId, UpdateShoppingListItemRequest(quantity, unit, checkedOff)) } + if (result is Result.Success) shoppingListDao.insertItem(result.data.item.toEntity(listId)) + return result + } + + suspend fun deleteItem(listId: String, itemId: String): Result { + val result = safeApiCall(gson) { api.deleteShoppingListItem(listId, itemId) } + if (result is Result.Success) shoppingListDao.deleteItemById(itemId) + return result + } + + private fun ShoppingListSummaryDto.toEntity() = ShoppingListEntity( + id = id, listName = listName, itemCount = itemCount, + checkedCount = checkedCount, lastModified = lastModified, createdAt = createdAt + ) + + private fun ShoppingListItemDto.toEntity(listId: String) = ShoppingListItemEntity( + id = id, shoppingListId = listId, itemName = itemName, + quantity = quantity, unit = unit, checkedOff = checkedOff, lastModified = lastModified + ) +} diff --git a/android/app/src/main/java/com/pantree/app/data/repository/SyncRepository.kt b/android/app/src/main/java/com/pantree/app/data/repository/SyncRepository.kt new file mode 100644 index 0000000..6df13d8 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/data/repository/SyncRepository.kt @@ -0,0 +1,65 @@ +package com.pantree.app.data.repository + +import com.google.gson.Gson +import com.pantree.app.data.local.SyncPreferences +import com.pantree.app.data.local.dao.PantryDao +import com.pantree.app.data.local.dao.ShoppingListDao +import com.pantree.app.data.local.entity.PantryItemEntity +import com.pantree.app.data.local.entity.ShoppingListEntity +import com.pantree.app.data.local.entity.ShoppingListItemEntity +import com.pantree.app.data.model.SyncResponse +import com.pantree.app.data.remote.ApiService +import com.pantree.app.util.Result +import com.pantree.app.util.safeApiCall +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SyncRepository @Inject constructor( + private val api: ApiService, + private val pantryDao: PantryDao, + private val shoppingListDao: ShoppingListDao, + private val syncPreferences: SyncPreferences, + private val gson: Gson +) { + suspend fun sync(): Result { + val since = syncPreferences.lastSyncTimestamp.first() + val result = safeApiCall(gson) { api.sync(since) } + if (result is Result.Success) { + val data = result.data + // Apply pantry delta + data.pantry.updated.forEach { + pantryDao.insertItem(PantryItemEntity(it.id, it.itemName, it.quantity, it.lastModified, it.createdAt)) + } + data.pantry.deleted.forEach { pantryDao.deleteItemById(it) } + + // Apply shopping list delta + data.shoppingLists.updated.forEach { list -> + shoppingListDao.insertList( + ShoppingListEntity( + id = list.id, listName = list.listName, + itemCount = list.items.updated.size, + checkedCount = list.items.updated.count { it.checkedOff }, + lastModified = list.lastModified, createdAt = list.lastModified + ) + ) + list.items.updated.forEach { item -> + shoppingListDao.insertItem( + ShoppingListItemEntity( + id = item.id, shoppingListId = list.id, + itemName = item.itemName, quantity = item.quantity, + unit = item.unit, checkedOff = item.checkedOff, + lastModified = item.lastModified + ) + ) + } + list.items.deleted.forEach { shoppingListDao.deleteItemById(it) } + } + data.shoppingLists.deleted.forEach { shoppingListDao.deleteListById(it) } + + syncPreferences.updateLastSync(data.serverTimestamp) + } + return result + } +} diff --git a/android/app/src/main/java/com/pantree/app/di/AppModule.kt b/android/app/src/main/java/com/pantree/app/di/AppModule.kt new file mode 100644 index 0000000..8f1c5c4 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/di/AppModule.kt @@ -0,0 +1,75 @@ +package com.pantree.app.di + +import android.content.Context +import androidx.room.Room +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.pantree.app.BuildConfig +import com.pantree.app.data.local.PantreeDatabase +import com.pantree.app.data.local.TokenStore +import com.pantree.app.data.local.dao.PantryDao +import com.pantree.app.data.local.dao.RecipeDao +import com.pantree.app.data.local.dao.ShoppingListDao +import com.pantree.app.data.remote.ApiService +import com.pantree.app.data.remote.AuthInterceptor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideGson(): Gson = GsonBuilder().create() + + @Provides + @Singleton + fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { + val logging = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY + else HttpLoggingInterceptor.Level.NONE + } + return OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(logging) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit = + Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + @Provides + @Singleton + fun provideApiService(retrofit: Retrofit): ApiService = + retrofit.create(ApiService::class.java) + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): PantreeDatabase = + Room.databaseBuilder(context, PantreeDatabase::class.java, "pantree.db") + .fallbackToDestructiveMigration() + .build() + + @Provides fun providePantryDao(db: PantreeDatabase): PantryDao = db.pantryDao() + @Provides fun provideRecipeDao(db: PantreeDatabase): RecipeDao = db.recipeDao() + @Provides fun provideShoppingListDao(db: PantreeDatabase): ShoppingListDao = db.shoppingListDao() +} diff --git a/android/app/src/main/java/com/pantree/app/sync/SyncManager.kt b/android/app/src/main/java/com/pantree/app/sync/SyncManager.kt new file mode 100644 index 0000000..c5f5c26 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/sync/SyncManager.kt @@ -0,0 +1,41 @@ +package com.pantree.app.sync + +import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.pantree.app.data.local.TokenStore +import com.pantree.app.data.repository.SyncRepository +import com.pantree.app.util.Result +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SyncManager @Inject constructor( + private val syncRepository: SyncRepository, + private val tokenStore: TokenStore, + @ApplicationContext private val context: Context +) : DefaultLifecycleObserver { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Called on app open (cold start) and foreground return. */ + override fun onStart(owner: LifecycleOwner) { + triggerSync() + } + + fun triggerSync() { + if (!tokenStore.isLoggedIn()) return + scope.launch { + when (val result = syncRepository.sync()) { + is Result.Success -> { /* Delta applied to Room, UI observes Flow */ } + is Result.Error -> { /* Log silently; cached data still shown */ } + is Result.NetworkError -> { /* Offline — cached data shown, offline banner visible */ } + } + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/components/CommonComponents.kt b/android/app/src/main/java/com/pantree/app/ui/components/CommonComponents.kt new file mode 100644 index 0000000..13a5e7e --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/components/CommonComponents.kt @@ -0,0 +1,147 @@ +package com.pantree.app.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingState( + modifier: Modifier = Modifier, + message: String = "Loading..." +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} + +@Composable +fun ErrorState( + message: String, + onRetry: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "\uD83D\uDE15", + style = MaterialTheme.typography.headlineLarge + ) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + if (onRetry != null) { + Button(onClick = onRetry) { + Text("Try again") + } + } + } + } +} + +@Composable +fun EmptyState( + emoji: String, + title: String, + subtitle: String, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(text = emoji, style = MaterialTheme.typography.headlineLarge) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + if (actionLabel != null && onAction != null) { + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onAction) { + Text(actionLabel) + } + } + } + } +} + +@Composable +fun OfflineBanner(modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + text = "\uD83D\uDCF5 You're offline. Showing cached data.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } +} + +@Composable +fun PantreeTopBar( + title: String, + onNavigateBack: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + TopAppBar( + title = { Text(title, style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + }, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) +} diff --git a/android/app/src/main/java/com/pantree/app/ui/navigation/PantreeNavGraph.kt b/android/app/src/main/java/com/pantree/app/ui/navigation/PantreeNavGraph.kt new file mode 100644 index 0000000..2d0edf7 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/navigation/PantreeNavGraph.kt @@ -0,0 +1,120 @@ +package com.pantree.app.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import com.pantree.app.ui.screens.auth.ForgotPasswordScreen +import com.pantree.app.ui.screens.auth.ResetPasswordScreen +import com.pantree.app.ui.screens.auth.SignInScreen +import com.pantree.app.ui.screens.auth.SignUpScreen +import com.pantree.app.ui.screens.auth.SplashScreen +import com.pantree.app.ui.screens.pantry.PantryScreen +import com.pantree.app.ui.screens.recipe.RecipeDetailScreen +import com.pantree.app.ui.screens.recipe.RecipesScreen +import com.pantree.app.ui.screens.settings.SettingsScreen +import com.pantree.app.ui.screens.shopping.ShoppingListDetailScreen +import com.pantree.app.ui.screens.shopping.ShoppingListsScreen + +@Composable +fun PantreeNavGraph() { + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = Screen.Splash.route) { + + composable(Screen.Splash.route) { + SplashScreen( + onNavigateToSignIn = { navController.navigate(Screen.SignIn.route) { popUpTo(Screen.Splash.route) { inclusive = true } } }, + onNavigateToPantry = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.Splash.route) { inclusive = true } } } + ) + } + + composable(Screen.SignIn.route) { + SignInScreen( + onSignInSuccess = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.SignIn.route) { inclusive = true } } }, + onNavigateToSignUp = { navController.navigate(Screen.SignUp.route) }, + onNavigateToForgotPassword = { navController.navigate(Screen.ForgotPassword.route) } + ) + } + + composable(Screen.SignUp.route) { + SignUpScreen( + onSignUpSuccess = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.SignIn.route) { inclusive = true } } }, + onNavigateBack = { navController.popBackStack() } + ) + } + + composable(Screen.ForgotPassword.route) { + ForgotPasswordScreen(onNavigateBack = { navController.popBackStack() }) + } + + composable( + route = Screen.ResetPassword.route, + arguments = listOf(navArgument("token") { type = NavType.StringType }), + deepLinks = listOf(navDeepLink { uriPattern = "https://pantree.app/reset-password?token={token}" }) + ) { backStackEntry -> + ResetPasswordScreen( + token = backStackEntry.arguments?.getString("token") ?: "", + onResetSuccess = { navController.navigate(Screen.SignIn.route) { popUpTo(0) { inclusive = true } } } + ) + } + + composable(Screen.Pantry.route) { + PantryScreen( + onNavigateToRecipes = { navController.navigate(Screen.Recipes.route) }, + onNavigateToShoppingLists = { navController.navigate(Screen.ShoppingLists.route) }, + onNavigateToSettings = { navController.navigate(Screen.Settings.route) } + ) + } + + composable(Screen.Recipes.route) { + RecipesScreen( + onRecipeClick = { id -> navController.navigate(Screen.RecipeDetail.createRoute(id)) }, + onNavigateBack = { navController.popBackStack() } + ) + } + + composable( + route = Screen.RecipeDetail.route, + arguments = listOf(navArgument("recipeId") { type = NavType.StringType }) + ) { backStackEntry -> + RecipeDetailScreen( + recipeId = backStackEntry.arguments?.getString("recipeId") ?: "", + onNavigateBack = { navController.popBackStack() }, + onAddToList = { listId -> navController.navigate(Screen.ShoppingListDetail.createRoute(listId)) } + ) + } + + composable(Screen.ShoppingLists.route) { + ShoppingListsScreen( + onListClick = { id -> navController.navigate(Screen.ShoppingListDetail.createRoute(id)) }, + onNavigateBack = { navController.popBackStack() } + ) + } + + composable( + route = Screen.ShoppingListDetail.route, + arguments = listOf(navArgument("listId") { type = NavType.StringType }) + ) { backStackEntry -> + ShoppingListDetailScreen( + listId = backStackEntry.arguments?.getString("listId") ?: "", + onNavigateBack = { navController.popBackStack() }, + onNavigateToRecipes = { navController.navigate(Screen.Recipes.route) } + ) + } + + composable(Screen.Settings.route) { + SettingsScreen( + onSignOut = { navController.navigate(Screen.SignIn.route) { popUpTo(0) { inclusive = true } } }, + onNavigateBack = { navController.popBackStack() } + ) + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/navigation/Screen.kt b/android/app/src/main/java/com/pantree/app/ui/navigation/Screen.kt new file mode 100644 index 0000000..a29878e --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/navigation/Screen.kt @@ -0,0 +1,24 @@ +package com.pantree.app.ui.navigation + +sealed class Screen(val route: String) { + // Auth + object Splash : Screen("splash") + object SignIn : Screen("signin") + object SignUp : Screen("signup") + object ForgotPassword : Screen("forgot_password") + object ResetPassword : Screen("reset_password/{token}") { + fun createRoute(token: String) = "reset_password/$token" + } + + // Main + object Pantry : Screen("pantry") + object Recipes : Screen("recipes") + object RecipeDetail : Screen("recipe/{recipeId}") { + fun createRoute(id: String) = "recipe/$id" + } + object ShoppingLists : Screen("shopping_lists") + object ShoppingListDetail : Screen("shopping_list/{listId}") { + fun createRoute(id: String) = "shopping_list/$id" + } + object Settings : Screen("settings") +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/auth/AuthViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/screens/auth/AuthViewModel.kt new file mode 100644 index 0000000..6be762a --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/auth/AuthViewModel.kt @@ -0,0 +1,93 @@ +package com.pantree.app.ui.screens.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pantree.app.data.repository.AuthRepository +import com.pantree.app.util.Result +import com.pantree.app.util.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class AuthUiState( + val isLoading: Boolean = false, + val error: String? = null, + val success: Boolean = false, + val pendingDeletionDate: String? = null +) + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun signup(email: String, password: String, name: String) { + viewModelScope.launch { + _uiState.value = AuthUiState(isLoading = true) + when (val result = authRepository.signup(email, password, name)) { + is Result.Success -> _uiState.value = AuthUiState(success = true) + is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage()) + is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.") + } + } + } + + fun signin(email: String, password: String) { + viewModelScope.launch { + _uiState.value = AuthUiState(isLoading = true) + when (val result = authRepository.signin(email, password)) { + is Result.Success -> _uiState.value = AuthUiState(success = true) + is Result.Error -> { + val pendingDate = if (result.code == "ACCOUNT_PENDING_DELETION") result.message else null + _uiState.value = AuthUiState(error = result.toUserMessage(), pendingDeletionDate = pendingDate) + } + is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.") + } + } + } + + fun googleAuth(idToken: String) { + viewModelScope.launch { + _uiState.value = AuthUiState(isLoading = true) + when (val result = authRepository.googleAuth(idToken)) { + is Result.Success -> _uiState.value = AuthUiState(success = true) + is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage()) + is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.") + } + } + } + + fun requestPasswordReset(email: String) { + viewModelScope.launch { + _uiState.value = AuthUiState(isLoading = true) + when (authRepository.requestPasswordReset(email)) { + is Result.Success -> _uiState.value = AuthUiState(success = true) + is Result.Error -> _uiState.value = AuthUiState(success = true) // Always show success (anti-enumeration) + is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.") + } + } + } + + fun confirmPasswordReset(token: String, newPassword: String) { + viewModelScope.launch { + _uiState.value = AuthUiState(isLoading = true) + when (val result = authRepository.confirmPasswordReset(token, newPassword)) { + is Result.Success -> _uiState.value = AuthUiState(success = true) + is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage()) + is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.") + } + } + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + fun isLoggedIn() = authRepository.isLoggedIn() +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/auth/ForgotPasswordScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/auth/ForgotPasswordScreen.kt new file mode 100644 index 0000000..1130106 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/auth/ForgotPasswordScreen.kt @@ -0,0 +1,153 @@ +package com.pantree.app.ui.screens.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun ForgotPasswordScreen( + onNavigateBack: () -> Unit, + viewModel: AuthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var email by remember { mutableStateOf("") } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Reset password") }, + navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (uiState.success) { + // Success state + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("📬", style = MaterialTheme.typography.headlineLarge) + Text("Check your inbox", style = MaterialTheme.typography.titleLarge) + Text( + "If an account exists for $email, we've sent a reset link. It expires in 1 hour.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onNavigateBack) { Text("Back to sign in") } + } + } + } else { + Text("Enter your email and we'll send you a reset link.", style = MaterialTheme.typography.bodyMedium) + + if (uiState.error != null) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth() + ) { + Text(uiState.error!!, modifier = Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer) + } + } + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { viewModel.requestPasswordReset(email.trim()) }, + enabled = !uiState.isLoading && email.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(52.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Send reset link") + } + } + } + } + } +} + +@Composable +fun ResetPasswordScreen( + token: String, + onResetSuccess: () -> Unit, + viewModel: AuthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var newPassword by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + val passwordsMatch = newPassword == confirmPassword && newPassword.isNotEmpty() + + LaunchedEffect(uiState.success) { + if (uiState.success) onResetSuccess() + } + + Scaffold(topBar = { TopAppBar(title = { Text("New password") }) }) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Choose a new password for your account.", style = MaterialTheme.typography.bodyMedium) + + if (uiState.error != null) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth() + ) { + Text(uiState.error!!, modifier = Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer) + } + } + + OutlinedTextField( + value = newPassword, + onValueChange = { newPassword = it; viewModel.clearError() }, + label = { Text("New password") }, + supportingText = { Text("At least 8 characters") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + label = { Text("Confirm password") }, + isError = confirmPassword.isNotEmpty() && !passwordsMatch, + supportingText = { if (confirmPassword.isNotEmpty() && !passwordsMatch) Text("Passwords don't match") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { viewModel.confirmPasswordReset(token, newPassword) }, + enabled = !uiState.isLoading && passwordsMatch && newPassword.length >= 8, + modifier = Modifier.fillMaxWidth().height(52.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Update password") + } + } + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/auth/SignInScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/auth/SignInScreen.kt new file mode 100644 index 0000000..cb986a1 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/auth/SignInScreen.kt @@ -0,0 +1,133 @@ +package com.pantree.app.ui.screens.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun SignInScreen( + onSignInSuccess: () -> Unit, + onNavigateToSignUp: () -> Unit, + onNavigateToForgotPassword: () -> Unit, + viewModel: AuthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.success) { + if (uiState.success) onSignInSuccess() + } + + Scaffold { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(Modifier.height(48.dp)) + Text("🌿", style = MaterialTheme.typography.headlineLarge) + Spacer(Modifier.height(8.dp)) + Text("Welcome back", style = MaterialTheme.typography.headlineMedium) + Text( + "Sign in to your pantry", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(Modifier.height(32.dp)) + + // Error banner + if (uiState.error != null) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = uiState.error!!, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) + } + Spacer(Modifier.height(16.dp)) + } + + OutlinedTextField( + value = email, + onValueChange = { email = it; viewModel.clearError() }, + label = { Text("Email") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it; viewModel.clearError() }, + label = { Text("Password") }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + TextButton( + onClick = onNavigateToForgotPassword, + modifier = Modifier.align(Alignment.End) + ) { + Text("Forgot password?") + } + Spacer(Modifier.height(8.dp)) + Button( + onClick = { viewModel.signin(email.trim(), password) }, + enabled = !uiState.isLoading && email.isNotBlank() && password.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(52.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Sign in") + } + } + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text(" or ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Don't have an account?", style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onNavigateToSignUp) { Text("Sign up") } + } + Spacer(Modifier.height(48.dp)) + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/auth/SignUpScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/auth/SignUpScreen.kt new file mode 100644 index 0000000..9daa7e6 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/auth/SignUpScreen.kt @@ -0,0 +1,124 @@ +package com.pantree.app.ui.screens.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun SignUpScreen( + onSignUpSuccess: () -> Unit, + onNavigateBack: () -> Unit, + viewModel: AuthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var name by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.success) { + if (uiState.success) onSignUpSuccess() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Create account") }, + navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Spacer(Modifier.height(16.dp)) + Text("Let's get you set up", style = MaterialTheme.typography.headlineSmall) + Text( + "Your pantry awaits.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(Modifier.height(8.dp)) + + if (uiState.error != null) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = uiState.error!!, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + OutlinedTextField( + value = name, + onValueChange = { name = it; viewModel.clearError() }, + label = { Text("Full name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = email, + onValueChange = { email = it; viewModel.clearError() }, + label = { Text("Email") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = password, + onValueChange = { password = it; viewModel.clearError() }, + label = { Text("Password") }, + supportingText = { Text("At least 8 characters") }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = null + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { viewModel.signup(email.trim(), password, name.trim()) }, + enabled = !uiState.isLoading && name.isNotBlank() && email.isNotBlank() && password.length >= 8, + modifier = Modifier.fillMaxWidth().height(52.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Create account") + } + } + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/auth/SplashScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/auth/SplashScreen.kt new file mode 100644 index 0000000..833abe7 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/auth/SplashScreen.kt @@ -0,0 +1,31 @@ +package com.pantree.app.ui.screens.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun SplashScreen( + onNavigateToSignIn: () -> Unit, + onNavigateToPantry: () -> Unit, + viewModel: AuthViewModel = hiltViewModel() +) { + val isLoggedIn = remember { viewModel.isLoggedIn() } + + LaunchedEffect(Unit) { + if (isLoggedIn) onNavigateToPantry() else onNavigateToSignIn() + } + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("🌿", style = MaterialTheme.typography.headlineLarge) + Text("Pantree", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.primary) + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/pantry/PantryScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/pantry/PantryScreen.kt new file mode 100644 index 0000000..59783e1 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/pantry/PantryScreen.kt @@ -0,0 +1,253 @@ +package com.pantree.app.ui.screens.pantry + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pantree.app.data.local.entity.PantryItemEntity +import com.pantree.app.ui.components.EmptyState +import com.pantree.app.ui.components.ErrorState +import com.pantree.app.ui.components.LoadingState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PantryScreen( + onNavigateToRecipes: () -> Unit, + onNavigateToShoppingLists: () -> Unit, + onNavigateToSettings: () -> Unit, + viewModel: PantryViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showAddDialog by remember { mutableStateOf(false) } + var editingItem by remember { mutableStateOf(null) } + var deletingItem by remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.actionSuccess) { + uiState.actionSuccess?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearActionMessages() + } + } + LaunchedEffect(uiState.actionError) { + uiState.actionError?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearActionMessages() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("My Pantry") }, + actions = { + IconButton(onClick = onNavigateToRecipes) { + Icon(Icons.Default.MenuBook, contentDescription = "Recipes", tint = MaterialTheme.colorScheme.onPrimary) + } + IconButton(onClick = onNavigateToShoppingLists) { + Icon(Icons.Default.ShoppingCart, contentDescription = "Shopping Lists", tint = MaterialTheme.colorScheme.onPrimary) + } + IconButton(onClick = onNavigateToSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings", tint = MaterialTheme.colorScheme.onPrimary) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showAddDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "Add item") + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when { + uiState.isLoading && uiState.items.isEmpty() -> LoadingState(message = "Loading your pantry...") + uiState.error != null && uiState.items.isEmpty() -> ErrorState( + message = uiState.error!!, + onRetry = { viewModel.refresh() } + ) + uiState.items.isEmpty() -> EmptyState( + emoji = "\uD83E\uDED9", + title = "Your pantry is empty", + subtitle = "Add ingredients you have on hand and we'll tell you what you can cook.", + actionLabel = "Add your first item", + onAction = { showAddDialog = true } + ) + else -> { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + Text( + "${uiState.items.size} item${if (uiState.items.size != 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(bottom = 4.dp) + ) + } + items(uiState.items, key = { it.id }) { item -> + PantryItemCard( + item = item, + onEdit = { editingItem = item }, + onDelete = { deletingItem = item } + ) + } + item { Spacer(Modifier.height(80.dp)) } + } + } + } + } + } + + if (showAddDialog) { + AddPantryItemDialog( + onDismiss = { showAddDialog = false }, + onConfirm = { name, qty -> + viewModel.addItem(name, qty) + showAddDialog = false + } + ) + } + + editingItem?.let { item -> + EditPantryItemDialog( + item = item, + onDismiss = { editingItem = null }, + onConfirm = { qty -> + viewModel.updateItem(item.id, qty) + editingItem = null + } + ) + } + + deletingItem?.let { item -> + AlertDialog( + onDismissRequest = { deletingItem = null }, + title = { Text("Remove item?") }, + text = { Text("Remove \"${item.itemName}\" from your pantry?") }, + confirmButton = { + TextButton(onClick = { viewModel.deleteItem(item.id); deletingItem = null }) { + Text("Remove", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { deletingItem = null }) { Text("Cancel") } } + ) + } +} + +@Composable +fun PantryItemCard( + item: PantryItemEntity, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(item.itemName, style = MaterialTheme.typography.titleMedium) + Text( + "Qty: ${item.quantity}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.primary) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +fun AddPantryItemDialog(onDismiss: () -> Unit, onConfirm: (String, Int) -> Unit) { + var name by remember { mutableStateOf("") } + var quantityText by remember { mutableStateOf("1") } + val quantity = quantityText.toIntOrNull() + val isValid = name.isNotBlank() && quantity != null && quantity > 0 + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add to pantry") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Item name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = quantityText, + onValueChange = { quantityText = it }, + label = { Text("Quantity") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + isError = quantityText.isNotEmpty() && quantity == null, + supportingText = { if (quantityText.isNotEmpty() && quantity == null) Text("Must be a whole number") }, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton(onClick = { if (isValid) onConfirm(name, quantity!!) }, enabled = isValid) { + Text("Add") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} + +@Composable +fun EditPantryItemDialog(item: PantryItemEntity, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) { + var quantityText by remember { mutableStateOf(item.quantity.toString()) } + val quantity = quantityText.toIntOrNull() + val isValid = quantity != null && quantity > 0 + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit quantity") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(item.itemName, style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = quantityText, + onValueChange = { quantityText = it }, + label = { Text("Quantity") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + isError = quantityText.isNotEmpty() && !isValid, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton(onClick = { if (isValid) onConfirm(quantity!!) }, enabled = isValid) { + Text("Save") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/pantry/PantryViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/screens/pantry/PantryViewModel.kt new file mode 100644 index 0000000..e08c9c8 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/pantry/PantryViewModel.kt @@ -0,0 +1,84 @@ +package com.pantree.app.ui.screens.pantry + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pantree.app.data.local.entity.PantryItemEntity +import com.pantree.app.data.repository.PantryRepository +import com.pantree.app.util.Result +import com.pantree.app.util.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class PantryUiState( + val isLoading: Boolean = false, + val items: List = emptyList(), + val error: String? = null, + val actionError: String? = null, + val actionSuccess: String? = null +) + +@HiltViewModel +class PantryViewModel @Inject constructor( + private val pantryRepository: PantryRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(PantryUiState(isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Observe local cache immediately + viewModelScope.launch { + pantryRepository.getLocalItems().collect { items -> + _uiState.update { it.copy(items = items, isLoading = false) } + } + } + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + when (val result = pantryRepository.fetchAndCacheItems()) { + is Result.Success -> _uiState.update { it.copy(isLoading = false) } + is Result.Error -> _uiState.update { it.copy(isLoading = false, error = result.toUserMessage()) } + is Result.NetworkError -> _uiState.update { it.copy(isLoading = false, error = null) } // Show cached, offline banner handled elsewhere + } + } + } + + fun addItem(name: String, quantity: Int) { + viewModelScope.launch { + when (val result = pantryRepository.addItem(name.trim(), quantity)) { + is Result.Success -> _uiState.update { it.copy(actionSuccess = "${result.data.item.itemName} added to pantry.", actionError = null) } + is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) } + is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") } + } + } + } + + fun updateItem(id: String, quantity: Int) { + viewModelScope.launch { + when (val result = pantryRepository.updateItem(id, quantity)) { + is Result.Success -> _uiState.update { it.copy(actionSuccess = "Updated.", actionError = null) } + is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) } + is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") } + } + } + } + + fun deleteItem(id: String) { + viewModelScope.launch { + when (val result = pantryRepository.deleteItem(id)) { + is Result.Success -> _uiState.update { it.copy(actionSuccess = "Item removed.", actionError = null) } + is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) } + is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") } + } + } + } + + fun clearActionMessages() { + _uiState.update { it.copy(actionError = null, actionSuccess = null) } + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipeDetailScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipeDetailScreen.kt new file mode 100644 index 0000000..16af259 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipeDetailScreen.kt @@ -0,0 +1,187 @@ +package com.pantree.app.ui.screens.recipe + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pantree.app.data.model.RecipeIngredientDto +import com.pantree.app.ui.components.ErrorState +import com.pantree.app.ui.components.LoadingState +import com.pantree.app.ui.theme.SuccessGreen + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecipeDetailScreen( + recipeId: String, + onNavigateBack: () -> Unit, + onAddToList: (String) -> Unit, + viewModel: RecipeViewModel = hiltViewModel() +) { + val uiState by viewModel.detailState.collectAsStateWithLifecycle() + + LaunchedEffect(recipeId) { + viewModel.loadRecipeDetail(recipeId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(uiState.recipe?.name ?: "Recipe") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + ) { padding -> + when { + uiState.isLoading -> LoadingState(modifier = Modifier.padding(padding), message = "Loading recipe...") + uiState.error != null -> ErrorState( + modifier = Modifier.padding(padding), + message = uiState.error!!, + onRetry = { viewModel.loadRecipeDetail(recipeId) } + ) + uiState.recipe != null -> { + val recipe = uiState.recipe!! + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(recipe.name, style = MaterialTheme.typography.headlineSmall) + Text( + "${recipe.scaledServings} servings", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + if (recipe.canMake) { + Surface( + color = SuccessGreen.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.small + ) { + Text( + "\u2705 Ready to cook", + style = MaterialTheme.typography.labelLarge, + color = SuccessGreen, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + } + + // Scale selector + item { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text("Scale recipe", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(1, 2, 3).forEach { scale -> + FilterChip( + selected = uiState.scaleFactor == scale, + onClick = { viewModel.setScale(recipeId, scale) }, + label = { Text("${scale}x") } + ) + } + } + } + } + } + + // Ingredients + item { + Text( + "Ingredients (${recipe.availableIngredientCount}/${recipe.ingredientCount} in pantry)", + style = MaterialTheme.typography.titleMedium + ) + } + items(recipe.ingredients) { ingredient -> + IngredientRow(ingredient = ingredient) + } + + // Instructions + item { + Text("Instructions", style = MaterialTheme.typography.titleMedium) + } + item { + Card { + Text( + recipe.instructions, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp) + ) + } + } + + // Add to shopping list button + item { + Spacer(Modifier.height(8.dp)) + Button( + onClick = { /* TODO: show list picker dialog */ }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.ShoppingCart, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Add to shopping list") + } + Spacer(Modifier.height(16.dp)) + } + } + } + } + } +} + +@Composable +fun IngredientRow(ingredient: RecipeIngredientDto) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (ingredient.inPantry) SuccessGreen.copy(alpha = 0.07f) + else MaterialTheme.colorScheme.surface + ) + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + if (ingredient.inPantry) "\u2705" else "\u274C", + modifier = Modifier.width(28.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text(ingredient.itemName, style = MaterialTheme.typography.bodyLarge) + } + Text( + "${formatQuantity(ingredient.quantity)} ${ingredient.unit}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } +} + +fun formatQuantity(qty: Double): String { + return if (qty == qty.toLong().toDouble()) qty.toLong().toString() + else String.format("%.2f", qty).trimEnd('0').trimEnd('.') +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipeViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipeViewModel.kt new file mode 100644 index 0000000..0e8327d --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipeViewModel.kt @@ -0,0 +1,83 @@ +package com.pantree.app.ui.screens.recipe + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pantree.app.data.local.entity.RecipeEntity +import com.pantree.app.data.model.RecipeDetailDto +import com.pantree.app.data.repository.RecipeRepository +import com.pantree.app.util.Result +import com.pantree.app.util.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class RecipesUiState( + val isLoading: Boolean = false, + val recipes: List = emptyList(), + val error: String? = null, + val currentFilter: String = "all", + val searchQuery: String = "", + val currentPage: Int = 1, + val totalPages: Int = 1 +) + +data class RecipeDetailUiState( + val isLoading: Boolean = false, + val recipe: RecipeDetailDto? = null, + val error: String? = null, + val scaleFactor: Int = 1 +) + +@HiltViewModel +class RecipeViewModel @Inject constructor( + private val recipeRepository: RecipeRepository +) : ViewModel() { + + private val _listState = MutableStateFlow(RecipesUiState(isLoading = true)) + val listState: StateFlow = _listState.asStateFlow() + + private val _detailState = MutableStateFlow(RecipeDetailUiState()) + val detailState: StateFlow = _detailState.asStateFlow() + + init { + viewModelScope.launch { + recipeRepository.getLocalRecipes().collect { recipes -> + _listState.update { it.copy(recipes = recipes, isLoading = false) } + } + } + fetchRecipes() + } + + fun fetchRecipes(filter: String = _listState.value.currentFilter, search: String? = null, page: Int = 1) { + viewModelScope.launch { + _listState.update { it.copy(isLoading = true, error = null, currentFilter = filter, searchQuery = search ?: "") } + when (val result = recipeRepository.fetchRecipes(filter, page, search)) { + is Result.Success -> _listState.update { + it.copy( + isLoading = false, + currentPage = result.data.pagination.page, + totalPages = result.data.pagination.totalPages + ) + } + is Result.Error -> _listState.update { it.copy(isLoading = false, error = result.toUserMessage()) } + is Result.NetworkError -> _listState.update { it.copy(isLoading = false) } + } + } + } + + fun loadRecipeDetail(recipeId: String, scale: Int = 1) { + viewModelScope.launch { + _detailState.value = RecipeDetailUiState(isLoading = true, scaleFactor = scale) + when (val result = recipeRepository.getRecipeById(recipeId, scale)) { + is Result.Success -> _detailState.value = RecipeDetailUiState(recipe = result.data.recipe, scaleFactor = scale) + is Result.Error -> _detailState.value = RecipeDetailUiState(error = result.toUserMessage()) + is Result.NetworkError -> _detailState.value = RecipeDetailUiState(error = "No internet connection.") + } + } + } + + fun setScale(recipeId: String, scale: Int) { + if (scale in 1..3) loadRecipeDetail(recipeId, scale) + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipesScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipesScreen.kt new file mode 100644 index 0000000..20569ba --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/recipe/RecipesScreen.kt @@ -0,0 +1,168 @@ +package com.pantree.app.ui.screens.recipe + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pantree.app.data.local.entity.RecipeEntity +import com.pantree.app.ui.components.EmptyState +import com.pantree.app.ui.components.ErrorState +import com.pantree.app.ui.components.LoadingState +import com.pantree.app.ui.theme.SuccessGreen + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecipesScreen( + onRecipeClick: (String) -> Unit, + onNavigateBack: () -> Unit, + viewModel: RecipeViewModel = hiltViewModel() +) { + val uiState by viewModel.listState.collectAsStateWithLifecycle() + var searchText by remember { mutableStateOf("") } + var showSearch by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + if (showSearch) { + TopAppBar( + title = { + OutlinedTextField( + value = searchText, + onValueChange = { + searchText = it + viewModel.fetchRecipes(search = it.ifBlank { null }) + }, + placeholder = { Text("Search recipes...") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent) + ) + }, + navigationIcon = { + IconButton(onClick = { showSearch = false; searchText = ""; viewModel.fetchRecipes() }) { + Icon(Icons.Default.ArrowBack, "Close search", tint = MaterialTheme.colorScheme.onPrimary) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primary) + ) + } else { + TopAppBar( + title = { Text("Recipes") }, + navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary) } }, + actions = { + IconButton(onClick = { showSearch = true }) { + Icon(Icons.Default.Search, "Search", tint = MaterialTheme.colorScheme.onPrimary) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + } + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + // Filter chips + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf("all" to "All", "can_make" to "Can make", "can_partially_make" to "Partial").forEach { (value, label) -> + FilterChip( + selected = uiState.currentFilter == value, + onClick = { viewModel.fetchRecipes(filter = value) }, + label = { Text(label) } + ) + } + } + + when { + uiState.isLoading && uiState.recipes.isEmpty() -> LoadingState(message = "Finding recipes...") + uiState.error != null && uiState.recipes.isEmpty() -> ErrorState( + message = uiState.error!!, + onRetry = { viewModel.fetchRecipes() } + ) + uiState.recipes.isEmpty() -> EmptyState( + emoji = "\uD83D\uDCDA", + title = when (uiState.currentFilter) { + "can_make" -> "Nothing to cook yet" + "can_partially_make" -> "No partial matches" + else -> "No recipes found" + }, + subtitle = when (uiState.currentFilter) { + "can_make" -> "Add more ingredients to your pantry to unlock recipes." + "can_partially_make" -> "Try adding a few more pantry items." + else -> "Try a different search term." + } + ) + else -> { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(uiState.recipes, key = { it.id }) { recipe -> + RecipeCard(recipe = recipe, onClick = { onRecipeClick(recipe.id) }) + } + item { Spacer(Modifier.height(16.dp)) } + } + } + } + } + } +} + +@Composable +fun RecipeCard(recipe: RecipeEntity, onClick: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onClick) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(recipe.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + if (recipe.canMake) { + Surface( + color = SuccessGreen.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.small + ) { + Text( + "\u2705 Can make", + style = MaterialTheme.typography.labelLarge, + color = SuccessGreen, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + "${recipe.servings} servings", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Text( + "${recipe.availableIngredientCount}/${recipe.ingredientCount} ingredients", + style = MaterialTheme.typography.bodySmall, + color = if (recipe.canMake) SuccessGreen else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + if (!recipe.canMake && recipe.ingredientCount > 0) { + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { (recipe.availabilityPercentage / 100f).toFloat() }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/settings/SettingsScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..f0da6e0 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,132 @@ +package com.pantree.app.ui.screens.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onSignOut: () -> Unit, + onNavigateBack: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showDeleteConfirm by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.signedOut) { if (uiState.signedOut) onSignOut() } + LaunchedEffect(uiState.accountDeleted) { if (uiState.accountDeleted) onSignOut() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (uiState.error != null) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth() + ) { + Text( + uiState.error!!, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + Spacer(Modifier.height(8.dp)) + Text("Account", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(4.dp)) + + Card(modifier = Modifier.fillMaxWidth()) { + Column { + ListItem( + headlineContent = { Text("Sign out") }, + supportingContent = { Text("You can sign back in anytime.") }, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { viewModel.signOut() }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text("Sign out") + } + } + } + + Spacer(Modifier.height(16.dp)) + Text("Danger zone", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(4.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Delete account", style = MaterialTheme.typography.titleMedium) + Text( + "Your account will be scheduled for deletion. You have 15 days to change your mind — just sign back in.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + OutlinedButton( + onClick = { showDeleteConfirm = true }, + enabled = !uiState.isLoading, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp)) + } else { + Text("Delete my account") + } + } + } + } + } + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete account?") }, + text = { + Text( + "This will schedule your account for permanent deletion in 15 days. " + + "All your pantry items, recipes, and shopping lists will be removed. " + + "Sign back in within 15 days to cancel." + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.deleteAccount(); showDeleteConfirm = false }) { + Text("Yes, delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { Text("Keep my account") } + } + ) + } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/settings/SettingsViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/screens/settings/SettingsViewModel.kt new file mode 100644 index 0000000..84d9313 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/settings/SettingsViewModel.kt @@ -0,0 +1,51 @@ +package com.pantree.app.ui.screens.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pantree.app.data.repository.AuthRepository +import com.pantree.app.util.Result +import com.pantree.app.util.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SettingsUiState( + val isLoading: Boolean = false, + val error: String? = null, + val signedOut: Boolean = false, + val accountDeleted: Boolean = false +) + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun signOut() { + authRepository.logout() + _uiState.update { it.copy(signedOut = true) } + } + + fun deleteAccount() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val result = authRepository.deleteAccount()) { + is Result.Success -> { + authRepository.logout() + _uiState.update { it.copy(isLoading = false, accountDeleted = true) } + } + is Result.Error -> _uiState.update { it.copy(isLoading = false, error = result.toUserMessage()) } + is Result.NetworkError -> _uiState.update { it.copy(isLoading = false, error = "No internet connection.") } + } + } + } + + fun clearError() = _uiState.update { it.copy(error = null) } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListDetailScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListDetailScreen.kt new file mode 100644 index 0000000..a99dd3b --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListDetailScreen.kt @@ -0,0 +1,305 @@ +package com.pantree.app.ui.screens.shopping + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pantree.app.data.local.entity.ShoppingListItemEntity +import com.pantree.app.ui.components.EmptyState +import com.pantree.app.ui.components.ErrorState +import com.pantree.app.ui.components.LoadingState + +val ALLOWED_UNITS = listOf( + "cups", "tbsp", "tsp", "oz", "fl_oz", + "g", "kg", "ml", "l", + "pieces", "slices", "cloves", "pinch", + "whole", "can", "package", "bunch" +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShoppingListDetailScreen( + listId: String, + onNavigateBack: () -> Unit, + onNavigateToRecipes: () -> Unit, + viewModel: ShoppingListViewModel = hiltViewModel() +) { + val uiState by viewModel.detailState.collectAsStateWithLifecycle() + var showAddItemDialog by remember { mutableStateOf(false) } + var deletingItemId by remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(listId) { viewModel.loadListDetail(listId) } + + LaunchedEffect(uiState.actionSuccess) { + uiState.actionSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() } + } + LaunchedEffect(uiState.actionError) { + uiState.actionError?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() } + } + LaunchedEffect(uiState.addRecipesSuccess) { + uiState.addRecipesSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() } + } + + val uncheckedItems = uiState.items.filter { !it.checkedOff } + val checkedItems = uiState.items.filter { it.checkedOff } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(uiState.listName.ifBlank { "Shopping List" }) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary) + } + }, + actions = { + IconButton(onClick = onNavigateToRecipes) { + Icon(Icons.Default.MenuBook, "Add from recipes", tint = MaterialTheme.colorScheme.onPrimary) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showAddItemDialog = true }) { + Icon(Icons.Default.Add, "Add item") + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when { + uiState.isLoading && uiState.items.isEmpty() -> LoadingState(message = "Loading list...") + uiState.error != null && uiState.items.isEmpty() -> ErrorState( + message = uiState.error!!, + onRetry = { viewModel.loadListDetail(listId) } + ) + uiState.items.isEmpty() -> EmptyState( + emoji = "\uD83D\uDCCB", + title = "This list is empty", + subtitle = "Add items manually or pull in ingredients from a recipe.", + actionLabel = "Add an item", + onAction = { showAddItemDialog = true } + ) + else -> { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Progress summary + item { + if (uiState.items.isNotEmpty()) { + val progress = checkedItems.size.toFloat() / uiState.items.size + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "${checkedItems.size} of ${uiState.items.size} checked", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + } + } + } + + // Unchecked items + items(uncheckedItems, key = { it.id }) { item -> + ShoppingListItemRow( + item = item, + onToggle = { viewModel.toggleCheckOff(listId, item) }, + onDelete = { deletingItemId = item.id } + ) + } + + // Checked items section + if (checkedItems.isNotEmpty()) { + item { + Spacer(Modifier.height(8.dp)) + Text( + "In cart (${checkedItems.size})", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + Spacer(Modifier.height(4.dp)) + } + items(checkedItems, key = { it.id }) { item -> + ShoppingListItemRow( + item = item, + onToggle = { viewModel.toggleCheckOff(listId, item) }, + onDelete = { deletingItemId = item.id } + ) + } + } + + item { Spacer(Modifier.height(80.dp)) } + } + } + } + } + } + + if (showAddItemDialog) { + AddShoppingItemDialog( + onDismiss = { showAddItemDialog = false }, + onConfirm = { name, qty, unit -> + viewModel.addItem(listId, name, qty, unit) + showAddItemDialog = false + } + ) + } + + deletingItemId?.let { itemId -> + AlertDialog( + onDismissRequest = { deletingItemId = null }, + title = { Text("Remove item?") }, + text = { Text("Remove this item from the list?") }, + confirmButton = { + TextButton(onClick = { viewModel.deleteItem(listId, itemId); deletingItemId = null }) { + Text("Remove", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { deletingItemId = null }) { Text("Cancel") } } + ) + } +} + +@Composable +fun ShoppingListItemRow( + item: ShoppingListItemEntity, + onToggle: () -> Unit, + onDelete: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (item.checkedOff) + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = item.checkedOff, + onCheckedChange = { onToggle() } + ) + Column(modifier = Modifier.weight(1f)) { + Text( + item.itemName, + style = MaterialTheme.typography.bodyLarge.copy( + textDecoration = if (item.checkedOff) TextDecoration.LineThrough else null + ), + color = if (item.checkedOff) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.onSurface + ) + Text( + "${formatQty(item.quantity)} ${item.unit}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + } + } + } +} + +fun formatQty(qty: Double): String = + if (qty == qty.toLong().toDouble()) qty.toLong().toString() + else String.format("%.2f", qty).trimEnd('0').trimEnd('.') + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddShoppingItemDialog(onDismiss: () -> Unit, onConfirm: (String, Double, String) -> Unit) { + var name by remember { mutableStateOf("") } + var quantityText by remember { mutableStateOf("1") } + var selectedUnit by remember { mutableStateOf("pieces") } + var unitExpanded by remember { mutableStateOf(false) } + val quantity = quantityText.toDoubleOrNull() + val isValid = name.isNotBlank() && quantity != null && quantity > 0 + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add item") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Item name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = quantityText, + onValueChange = { quantityText = it }, + label = { Text("Qty") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + isError = quantityText.isNotEmpty() && quantity == null, + modifier = Modifier.weight(1f) + ) + ExposedDropdownMenuBox( + expanded = unitExpanded, + onExpandedChange = { unitExpanded = it }, + modifier = Modifier.weight(1.5f) + ) { + OutlinedTextField( + value = selectedUnit, + onValueChange = {}, + readOnly = true, + label = { Text("Unit") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) }, + modifier = Modifier.menuAnchor() + ) + ExposedDropdownMenu( + expanded = unitExpanded, + onDismissRequest = { unitExpanded = false } + ) { + ALLOWED_UNITS.forEach { unit -> + DropdownMenuItem( + text = { Text(unit) }, + onClick = { selectedUnit = unit; unitExpanded = false } + ) + } + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { if (isValid) onConfirm(name, quantity!!, selectedUnit) }, + enabled = isValid + ) { Text("Add") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListViewModel.kt b/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListViewModel.kt new file mode 100644 index 0000000..e9b8fdf --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListViewModel.kt @@ -0,0 +1,148 @@ +package com.pantree.app.ui.screens.shopping + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pantree.app.data.local.entity.ShoppingListEntity +import com.pantree.app.data.local.entity.ShoppingListItemEntity +import com.pantree.app.data.repository.ShoppingListRepository +import com.pantree.app.util.Result +import com.pantree.app.util.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ShoppingListsUiState( + val isLoading: Boolean = false, + val lists: List = emptyList(), + val error: String? = null, + val actionError: String? = null, + val actionSuccess: String? = null +) + +data class ShoppingListDetailUiState( + val isLoading: Boolean = false, + val listName: String = "", + val items: List = emptyList(), + val error: String? = null, + val actionError: String? = null, + val actionSuccess: String? = null, + val addRecipesSuccess: String? = null +) + +@HiltViewModel +class ShoppingListViewModel @Inject constructor( + private val shoppingListRepository: ShoppingListRepository +) : ViewModel() { + + private val _listsState = MutableStateFlow(ShoppingListsUiState(isLoading = true)) + val listsState: StateFlow = _listsState.asStateFlow() + + private val _detailState = MutableStateFlow(ShoppingListDetailUiState()) + val detailState: StateFlow = _detailState.asStateFlow() + + init { + viewModelScope.launch { + shoppingListRepository.getLocalLists().collect { lists -> + _listsState.update { it.copy(lists = lists, isLoading = false) } + } + } + fetchLists() + } + + fun fetchLists() { + viewModelScope.launch { + _listsState.update { it.copy(isLoading = true, error = null) } + when (val result = shoppingListRepository.fetchLists()) { + is Result.Success -> _listsState.update { it.copy(isLoading = false) } + is Result.Error -> _listsState.update { it.copy(isLoading = false, error = result.toUserMessage()) } + is Result.NetworkError -> _listsState.update { it.copy(isLoading = false) } + } + } + } + + fun createList(name: String) { + viewModelScope.launch { + when (val result = shoppingListRepository.createList(name.trim())) { + is Result.Success -> _listsState.update { it.copy(actionSuccess = "\"${result.data.shoppingList.listName}\" created.") } + is Result.Error -> _listsState.update { it.copy(actionError = result.toUserMessage()) } + is Result.NetworkError -> _listsState.update { it.copy(actionError = "No internet connection.") } + } + } + } + + fun deleteList(listId: String) { + viewModelScope.launch { + when (val result = shoppingListRepository.deleteList(listId)) { + is Result.Success -> _listsState.update { it.copy(actionSuccess = "List deleted.") } + is Result.Error -> _listsState.update { it.copy(actionError = result.toUserMessage()) } + is Result.NetworkError -> _listsState.update { it.copy(actionError = "No internet connection.") } + } + } + } + + fun loadListDetail(listId: String) { + viewModelScope.launch { + _detailState.update { it.copy(isLoading = true, error = null) } + // Observe local items immediately + launch { + shoppingListRepository.getLocalItems(listId).collect { items -> + _detailState.update { it.copy(items = items, isLoading = false) } + } + } + when (val result = shoppingListRepository.fetchListById(listId)) { + is Result.Success -> _detailState.update { + it.copy(isLoading = false, listName = result.data.shoppingList.listName) + } + is Result.Error -> _detailState.update { it.copy(isLoading = false, error = result.toUserMessage()) } + is Result.NetworkError -> _detailState.update { it.copy(isLoading = false) } + } + } + } + + fun addItem(listId: String, name: String, quantity: Double, unit: String) { + viewModelScope.launch { + when (val result = shoppingListRepository.addItem(listId, name.trim(), quantity, unit)) { + is Result.Success -> { + val msg = if (result.data.merged == true) + "Merged with existing ${result.data.item.itemName}." + else "${result.data.item.itemName} added." + _detailState.update { it.copy(actionSuccess = msg, actionError = null) } + } + is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) } + is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") } + } + } + } + + fun addRecipesToList(listId: String, recipeIds: List, scaleFactor: Int) { + viewModelScope.launch { + when (val result = shoppingListRepository.addRecipesToList(listId, recipeIds, scaleFactor)) { + is Result.Success -> _detailState.update { + it.copy(addRecipesSuccess = "Added ${result.data.recipesAdded} recipe(s). ${result.data.itemsMerged} items merged, ${result.data.itemsCreated} new.") + } + is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) } + is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") } + } + } + } + + fun toggleCheckOff(listId: String, item: ShoppingListItemEntity) { + viewModelScope.launch { + shoppingListRepository.updateItem(listId, item.id, checkedOff = !item.checkedOff) + } + } + + fun deleteItem(listId: String, itemId: String) { + viewModelScope.launch { + when (val result = shoppingListRepository.deleteItem(listId, itemId)) { + is Result.Success -> _detailState.update { it.copy(actionSuccess = "Item removed.") } + is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) } + is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") } + } + } + } + + fun clearListsMessages() = _listsState.update { it.copy(actionError = null, actionSuccess = null) } + fun clearDetailMessages() = _detailState.update { it.copy(actionError = null, actionSuccess = null, addRecipesSuccess = null) } +} diff --git a/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListsScreen.kt b/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListsScreen.kt new file mode 100644 index 0000000..eadc934 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/screens/shopping/ShoppingListsScreen.kt @@ -0,0 +1,175 @@ +package com.pantree.app.ui.screens.shopping + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pantree.app.data.local.entity.ShoppingListEntity +import com.pantree.app.ui.components.EmptyState +import com.pantree.app.ui.components.ErrorState +import com.pantree.app.ui.components.LoadingState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShoppingListsScreen( + onListClick: (String) -> Unit, + onNavigateBack: () -> Unit, + viewModel: ShoppingListViewModel = hiltViewModel() +) { + val uiState by viewModel.listsState.collectAsStateWithLifecycle() + var showCreateDialog by remember { mutableStateOf(false) } + var deletingList by remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.actionSuccess) { + uiState.actionSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearListsMessages() } + } + LaunchedEffect(uiState.actionError) { + uiState.actionError?.let { snackbarHostState.showSnackbar(it); viewModel.clearListsMessages() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Shopping Lists") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, "Create list") + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when { + uiState.isLoading && uiState.lists.isEmpty() -> LoadingState(message = "Loading your lists...") + uiState.error != null && uiState.lists.isEmpty() -> ErrorState( + message = uiState.error!!, + onRetry = { viewModel.fetchLists() } + ) + uiState.lists.isEmpty() -> EmptyState( + emoji = "\uD83D\uDED2", + title = "No shopping lists yet", + subtitle = "Create a list to start planning your next grocery run.", + actionLabel = "Create a list", + onAction = { showCreateDialog = true } + ) + else -> { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(uiState.lists, key = { it.id }) { list -> + ShoppingListCard( + list = list, + onClick = { onListClick(list.id) }, + onDelete = { deletingList = list } + ) + } + item { Spacer(Modifier.height(80.dp)) } + } + } + } + } + } + + if (showCreateDialog) { + CreateListDialog( + onDismiss = { showCreateDialog = false }, + onConfirm = { name -> viewModel.createList(name); showCreateDialog = false } + ) + } + + deletingList?.let { list -> + AlertDialog( + onDismissRequest = { deletingList = null }, + title = { Text("Delete list?") }, + text = { Text("\"${list.listName}\" and all its items will be permanently deleted.") }, + confirmButton = { + TextButton(onClick = { viewModel.deleteList(list.id); deletingList = null }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { deletingList = null }) { Text("Cancel") } } + ) + } +} + +@Composable +fun ShoppingListCard( + list: ShoppingListEntity, + onClick: () -> Unit, + onDelete: () -> Unit +) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onClick) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(list.listName, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "${list.itemCount} item${if (list.itemCount != 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + if (list.checkedCount > 0) { + Text( + "${list.checkedCount} checked", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)) + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +fun CreateListDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New shopping list") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("List name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { if (name.isNotBlank()) onConfirm(name) }, enabled = name.isNotBlank()) { + Text("Create") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} diff --git a/android/app/src/main/java/com/pantree/app/ui/theme/Color.kt b/android/app/src/main/java/com/pantree/app/ui/theme/Color.kt new file mode 100644 index 0000000..9109f75 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/theme/Color.kt @@ -0,0 +1,24 @@ +package com.pantree.app.ui.theme + +import androidx.compose.ui.graphics.Color + +// Brand palette +val PantreeGreen = Color(0xFF2D6A4F) +val PantreeGreenLight = Color(0xFF52B788) +val PantreeGreenDark = Color(0xFF1B4332) +val PantreeCream = Color(0xFFF8F4EF) +val PantreeOrange = Color(0xFFE76F51) +val PantreeOrangeLight = Color(0xFFF4A261) + +// Semantic +val SuccessGreen = Color(0xFF40916C) +val ErrorRed = Color(0xFFD62828) +val WarningAmber = Color(0xFFF4A261) +val NeutralGray = Color(0xFF6B7280) +val SurfaceWhite = Color(0xFFFFFFFF) +val BackgroundCream = Color(0xFFF8F4EF) + +// Dark theme +val PantreeGreenDarkTheme = Color(0xFF52B788) +val SurfaceDark = Color(0xFF1C1C1E) +val BackgroundDark = Color(0xFF121212) diff --git a/android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt b/android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt new file mode 100644 index 0000000..e122644 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt @@ -0,0 +1,65 @@ +package com.pantree.app.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val LightColorScheme = lightColorScheme( + primary = PantreeGreen, + onPrimary = SurfaceWhite, + primaryContainer = PantreeGreenLight, + onPrimaryContainer = PantreeGreenDark, + secondary = PantreeOrange, + onSecondary = SurfaceWhite, + secondaryContainer = PantreeOrangeLight, + background = BackgroundCream, + onBackground = PantreeGreenDark, + surface = SurfaceWhite, + onSurface = PantreeGreenDark, + error = ErrorRed, + onError = SurfaceWhite, + outline = NeutralGray +) + +private val DarkColorScheme = darkColorScheme( + primary = PantreeGreenDarkTheme, + onPrimary = PantreeGreenDark, + primaryContainer = PantreeGreenDark, + onPrimaryContainer = PantreeGreenLight, + secondary = PantreeOrangeLight, + onSecondary = SurfaceDark, + background = BackgroundDark, + onBackground = PantreeCream, + surface = SurfaceDark, + onSurface = PantreeCream, + error = ErrorRed, + onError = SurfaceWhite +) + +@Composable +fun PantreeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + MaterialTheme( + colorScheme = colorScheme, + typography = PantreeTypography, + content = content + ) +} diff --git a/android/app/src/main/java/com/pantree/app/ui/theme/Type.kt b/android/app/src/main/java/com/pantree/app/ui/theme/Type.kt new file mode 100644 index 0000000..98f8463 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/ui/theme/Type.kt @@ -0,0 +1,54 @@ +package com.pantree.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val PantreeTypography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 28.sp + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 22.sp + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp + ) +) diff --git a/android/app/src/main/java/com/pantree/app/util/Result.kt b/android/app/src/main/java/com/pantree/app/util/Result.kt new file mode 100644 index 0000000..dbe92a2 --- /dev/null +++ b/android/app/src/main/java/com/pantree/app/util/Result.kt @@ -0,0 +1,58 @@ +package com.pantree.app.util + +import com.google.gson.Gson +import com.pantree.app.data.model.ApiError +import retrofit2.Response + +sealed class Result { + data class Success(val data: T) : Result() + data class Error(val code: String, val message: String, val httpStatus: Int = 0) : Result() + object NetworkError : Result() +} + +suspend fun safeApiCall(gson: Gson, call: suspend () -> Response): Result { + return try { + val response = call() + if (response.isSuccessful) { + val body = response.body() + if (body != null) { + Result.Success(body) + } else if (response.code() == 204) { + @Suppress("UNCHECKED_CAST") + Result.Success(Unit as T) + } else { + Result.Error("EMPTY_BODY", "Empty response body", response.code()) + } + } else { + val errorBody = response.errorBody()?.string() + val apiError = try { + gson.fromJson(errorBody, ApiError::class.java) + } catch (e: Exception) { + null + } + Result.Error( + code = apiError?.code ?: "UNKNOWN_ERROR", + message = apiError?.error ?: "Something went wrong. Please try again.", + httpStatus = response.code() + ) + } + } catch (e: java.io.IOException) { + Result.NetworkError + } catch (e: Exception) { + Result.Error("UNKNOWN_ERROR", e.message ?: "An unexpected error occurred.") + } +} + +fun Result.Error.toUserMessage(): String = when (code) { + "VALIDATION_ERROR" -> message + "UNAUTHORIZED" -> "Your session has expired. Please sign in again." + "FORBIDDEN" -> message + "NOT_FOUND" -> "That item couldn't be found." + "CONFLICT" -> message + "DUPLICATE_ITEM" -> message + "ACCOUNT_PENDING_DELETION" -> message + "GONE" -> "This account no longer exists." + "INVALID_TOKEN" -> "That link has expired. Please request a new one." + "INVALID_GOOGLE_TOKEN" -> "Google sign-in failed. Please try again." + else -> "Something went wrong. Please try again." +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..18c9e35 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Pantree + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d9b49d1 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +