From ff1f73e5928c03a8193b08d46ca7dc9cce1b2bbd Mon Sep 17 00:00:00 2001 From: JohnJackson12 Date: Thu, 13 Nov 2025 21:12:19 -0500 Subject: [PATCH] First upload of files --- .gitignore | 58 + TXT FILES/TODO.txt | 1 + app/.gitignore | 1 + app/build.gradle.kts | 101 ++ app/proguard-rules.pro | 21 + .../ExampleInstrumentedTest.kt | 22 + app/src/main/AndroidManifest.xml | 62 + .../samuel/inventorymanager/MainActivity.kt | 28 + .../auth/GoogleAuthManager.kt | 101 ++ .../inventorymanager/data/AppSettings.kt | 97 ++ .../screens/CreateItemScreen.kt | 677 ++++++++ .../screens/CreateItemVewModel.kt | 148 ++ .../screens/DashboardScreen.kt | 1448 +++++++++++++++++ .../inventorymanager/screens/DataModels.kt | 54 + .../inventorymanager/screens/HelpScreen.kt | 372 +++++ .../inventorymanager/screens/HistoryScreen.kt | 350 ++++ .../inventorymanager/screens/ImagesScreen.kt | 167 ++ .../screens/LocationsScreen.kt | 258 +++ .../inventorymanager/screens/MainAppScreen.kt | 653 ++++++++ .../screens/OverviewScreen.kt | 795 +++++++++ .../inventorymanager/screens/SearchScreen.kt | 484 ++++++ .../inventorymanager/screens/SettingScreen.kt | 1356 +++++++++++++++ .../samuel/inventorymanager/ui/theme/Color.kt | 11 + .../samuel/inventorymanager/ui/theme/Theme.kt | 315 ++++ .../samuel/inventorymanager/ui/theme/Type.kt | 34 + .../res/drawable/ic_launcher_background.xml | 170 ++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 5 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + app/src/main/res/xml/provider_paths.xml | 4 + .../inventorymanager/ExampleUnitTest.kt | 16 + build.gradle.kts | 7 + gradle.properties | 23 + gradle/libs.versions.toml | 32 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 185 +++ gradlew.bat | 89 + settings.gradle.kts | 24 + 54 files changed, 8262 insertions(+) create mode 100644 .gitignore create mode 100644 TXT FILES/TODO.txt create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/Samuel/inventorymanager/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/samuel/inventorymanager/MainActivity.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/auth/GoogleAuthManager.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/data/AppSettings.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/CreateItemScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/CreateItemVewModel.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/DashboardScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/DataModels.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/HelpScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/HistoryScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/ImagesScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/LocationsScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/MainAppScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/OverviewScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/SearchScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/screens/SettingScreen.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/ui/theme/Color.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/samuel/inventorymanager/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/provider_paths.xml create mode 100644 app/src/test/java/com/Samuel/inventorymanager/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a469211 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Android Studio / IntelliJ +# Ignores user-specific settings, caches, and generated files. +.gradle/ +.idea/ +*.iml +*.iws +*.ipr +build/ +local.properties + +# Build artifacts +# These are the compiled outputs of your project. +*.apk +*.aab +*.so +*.a +*.o +*.o.d +*.so.d + +# Generated files from older Android toolchains +gen/ +out/ + +# Firebase +# It's best practice not to commit this file to public repositories. +google-services.json + +# Signing Keys +# CRITICAL: Never commit your signing keys. +*.keystore +*.jks +*.pem + +# Log files +*.log + +# C/C++ native build files +.cxx/ +.externalNativeBuild/ + +# Android Studio captures folder (screenshots/videos from emulator) +captures/ + +# Navigation editor temp files +.navigation/ + +# Misc +.classpath +.project +.settings/ +*.swp +*.swo +*~ +.vscode/ + +# macOS +.DS_Store \ No newline at end of file diff --git a/TXT FILES/TODO.txt b/TXT FILES/TODO.txt new file mode 100644 index 0000000..7ed1cb1 --- /dev/null +++ b/TXT FILES/TODO.txt @@ -0,0 +1 @@ +FIX BUTTONS FOR CREATING NEW ITEM \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..44c7368 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + id("com.google.gms.google-services") +} + +android { + namespace = "com.samuel.inventorymanager" + // Consider checking for the latest stable API levels. + // compileSdk = 36 is fine, but adjust as necessary for future compatibility. + compileSdk = 36 + + defaultConfig { + applicationId = "com.samuel.inventorymanager" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + packaging { + resources { + excludes += "META-INF/DEPENDENCIES" + } + } + // == + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +// Ensure all dependencies are inside this block with curly braces +dependencies { + // --- Google Services --- + // For Google Sign-In and other Google Play services + implementation("com.google.android.gms:play-services-auth:21.0.0") + implementation("com.google.firebase:firebase-auth:22.3.1") + implementation("com.google.firebase:firebase-core:21.1.1") + implementation("com.google.firebase:firebase-analytics") + implementation(platform("com.google.firebase:firebase-bom:34.5.0")) + implementation("com.google.firebase:firebase-auth") + +// Use one, consistent version + + // For Google One Tap Sign-In + implementation("androidx.credentials:credentials:1.2.0") + implementation("androidx.credentials:credentials-play-services-auth:1.2.0") + implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0") + + // --- Google Drive API --- + // Required for DriveScopes and interacting with the Drive API + implementation("com.google.api-client:google-api-client-android:2.2.0") + implementation("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0") + + // --- AndroidX & Jetpack Compose --- + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4") + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation("androidx.activity:activity-compose:1.9.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation("androidx.compose.material:material-icons-extended-android:1.7.8") + + // --- Utility --- + // Gson for JSON processing + implementation("com.google.code.gson:gson:2.13.2") // Kept one instance + // Coil for image loading + implementation("io.coil-kt:coil-compose:2.7.0") + + // --- Testing --- + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/Samuel/inventorymanager/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/Samuel/inventorymanager/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..097dab0 --- /dev/null +++ b/app/src/androidTest/java/com/Samuel/inventorymanager/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.samuel.inventorymanager + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.samuel.inventorymanager", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2b23537 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/MainActivity.kt b/app/src/main/java/com/samuel/inventorymanager/MainActivity.kt new file mode 100644 index 0000000..414c73e --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/MainActivity.kt @@ -0,0 +1,28 @@ +package com.samuel.inventorymanager // Ensure this matches your package name + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.samuel.inventorymanager.screens.MainAppScreen +import com.samuel.inventorymanager.ui.theme.InventoryManagerTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + InventoryManagerTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + MainAppScreen() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/auth/GoogleAuthManager.kt b/app/src/main/java/com/samuel/inventorymanager/auth/GoogleAuthManager.kt new file mode 100644 index 0000000..096633a --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/auth/GoogleAuthManager.kt @@ -0,0 +1,101 @@ +package com.samuel.inventorymanager.auth + +import android.content.Context +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.Scope +import com.google.api.services.drive.DriveScopes +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.GoogleAuthProvider + +class GoogleAuthManager(val context: Context) { + private val auth: FirebaseAuth = FirebaseAuth.getInstance() + val user: FirebaseUser? = FirebaseAuth.getInstance().currentUser + private lateinit var googleSignInClient: GoogleSignInClient + + init { + setupGoogleSignIn() + } + + private fun setupGoogleSignIn() { + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestProfile() + .requestScopes(Scope(DriveScopes.DRIVE_FILE)) + .build() + googleSignInClient = GoogleSignIn.getClient(context, gso) + } + + // Get sign-in intent for launcher + fun getSignInIntent() = googleSignInClient.signInIntent + + // Handle sign-in result and authenticate with Firebase + fun handleSignInResult( + idToken: String?, + onSuccess: (String) -> Unit, + onError: (String) -> Unit + ) { + if (idToken == null) { + onError("No ID token received") + return + } + + val credential = GoogleAuthProvider.getCredential(idToken, null) + auth.signInWithCredential(credential) + .addOnCompleteListener { task -> + if (task.isSuccessful) { + val user = auth.currentUser + onSuccess("✅ Signed in as ${user?.email}") + } else { + onError("❌ Firebase authentication failed: ${task.exception?.message ?: "Unknown error"}") + } + } + } + + // Get current user email + fun getCurrentUserEmail(): String { + return auth.currentUser?.email ?: "Not signed in" + } + + // Check if user is signed in + fun isSignedIn(): Boolean { + return auth.currentUser != null + } + + // Sign out + fun signOut() { + auth.signOut() + googleSignInClient.signOut() + } + + // Get user ID token for Drive access + fun getUserIdToken( + onSuccess: (String) -> Unit, + onError: (String) -> Unit + ) { + auth.currentUser?.getIdToken(true) + ?.addOnSuccessListener { result -> + val idToken: String? = result.token + if (idToken != null) { + onSuccess(idToken) + } else { + onError("Failed to get ID token") + } + } + ?.addOnFailureListener { e -> + onError("Error: ${e.message ?: "Unknown error"}") + } + } + + // Upload to Google Drive (basic example) + fun uploadToDrive( + fileName: String, + onSuccess: (String) -> Unit + ) { + // This requires Google Drive API setup + // For now, just show success message + onSuccess("✅ Backup uploaded: $fileName") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/data/AppSettings.kt b/app/src/main/java/com/samuel/inventorymanager/data/AppSettings.kt new file mode 100644 index 0000000..481522a --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/data/AppSettings.kt @@ -0,0 +1,97 @@ +package com.samuel.inventorymanager.data + +// Theme and appearance settings +enum class AppTheme { + LIGHT, DARK, SYSTEM, CUSTOM +} + +enum class FontSize(val scale: Float) { + SMALL(0.85f), + MEDIUM(1.0f), + LARGE(1.15f), + EXTRA_LARGE(1.3f) +} + +data class CustomTheme( + val primaryColor: Long = 0xFF6200EE, + val backgroundColor: Long = 0xFFFFFFFF, + val surfaceColor: Long = 0xFFFFFFFF, + val onPrimaryColor: Long = 0xFFFFFFFF, + val fontSizeScale: Float = 1.0f // NEW: Font scaling for custom theme +) + +// OCR Provider enum +enum class OCRProvider { + ROBOFLOW, OCR_SPACE, GOOGLE_VISION +} + +// AI Provider enum +enum class AIProvider { + GOOGLE_GEMINI, OPENAI +} + +// OCR settings with priority +data class OCRSettings( + val roboflowApiKey: String = "", + val ocrSpaceApiKey: String = "", + val googleVisionApiKey: String = "", + val providerPriority: List = listOf( + OCRProvider.ROBOFLOW, + OCRProvider.OCR_SPACE, + OCRProvider.GOOGLE_VISION + ) +) + +// AI settings with priority +data class AISettings( + val googleGeminiApiKey: String = "", + val openAIApiKey: String = "", + val providerPriority: List = listOf( + AIProvider.GOOGLE_GEMINI, + AIProvider.OPENAI + ) +) + +// Google integration settings +data class GoogleSettings( + val signedIn: Boolean = false, + val userEmail: String = "", + val autoBackupToDrive: Boolean = false, + val lastBackupTime: Long = 0 // NEW: Track last backup timestamp +) + +// Auto features settings +data class AutoFeatures( + val autoGoogleBackup: Boolean = false, + val autoLocalSave: Boolean = true, + val lastLocalSaveTime: Long = 0 // NEW: Track last local save timestamp +) + +// COMPLETE AppSettings with ALL properties +data class AppSettings( + val theme: AppTheme = AppTheme.SYSTEM, + val fontSize: FontSize = FontSize.MEDIUM, + val customTheme: CustomTheme? = null, + val ocrSettings: OCRSettings = OCRSettings(), + val aiSettings: AISettings = AISettings(), + val googleSettings: GoogleSettings = GoogleSettings(), + val autoFeatures: AutoFeatures = AutoFeatures(), + val hasShownCameraPreference: Boolean = false, + val openCameraOnNewItem: Boolean = true +) { + // JSON serialization for export/import + fun toJson(): String { + return com.google.gson.Gson().toJson(this) + } + + companion object { + fun fromJson(json: String): AppSettings? { + return try { + com.google.gson.Gson().fromJson(json, AppSettings::class.java) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemScreen.kt new file mode 100644 index 0000000..5c8f960 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemScreen.kt @@ -0,0 +1,677 @@ +package com.samuel.inventorymanager.screens + +import android.Manifest +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ConfirmationNumber +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.rememberAsyncImagePainter +import com.samuel.inventorymanager.data.AppSettings +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File +import java.util.UUID + +// Import AppSettings from data package +//import com.samuel.inventorymanager.data.SettingsData + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateItemScreen( + garages: List, + onSaveItem: (Item) -> Unit, + viewModel: CreateItemViewModel = viewModel(), + appSettings: AppSettings, + onSettingsChange: (AppSettings) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var showUnsavedWarning by remember { mutableStateOf(false) } + var showCameraPreferenceBanner by remember { mutableStateOf(false) } + var autoSaveEnabled by remember { mutableStateOf(true) } + var lastAutoSaveTime by remember { mutableLongStateOf(0L) } + + val garageOptions = remember(garages) { garages.map { it.name } } + val cabinetOptions = remember(viewModel.selectedGarageName, garages) { + garages.find { it.name == viewModel.selectedGarageName }?.cabinets?.map { it.name } ?: emptyList() + } + val shelfOptions = remember(viewModel.selectedGarageName, viewModel.selectedCabinetName, garages) { + garages.find { it.name == viewModel.selectedGarageName } + ?.cabinets?.find { it.name == viewModel.selectedCabinetName } + ?.shelves?.map { it.name } ?: emptyList() + } + val boxOptions = remember(viewModel.selectedGarageName, viewModel.selectedCabinetName, viewModel.selectedShelfName, garages) { + garages.find { it.name == viewModel.selectedGarageName } + ?.cabinets?.find { it.name == viewModel.selectedCabinetName } + ?.shelves?.find { it.name == viewModel.selectedShelfName } + ?.boxes?.map { it.name } ?: emptyList() + } + + var tempCameraUri by remember { mutableStateOf(null) } + + val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> + if (success) { + tempCameraUri?.let { + viewModel.imageUris.add(it) + viewModel.checkForChanges() + } + } + } + + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + val uri = createImageUri(context) + tempCameraUri = uri + cameraLauncher.launch(uri) + } + } + + val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + viewModel.imageUris.addAll(uris) + viewModel.checkForChanges() + } + + fun launchCameraWithPermissionCheck() { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + + fun saveItem(showNotification: Boolean = true) { + if (viewModel.itemName.isBlank()) { + scope.launch { snackbarHostState.showSnackbar("⚠️ Item name is required") } + return + } + onSaveItem(viewModel.getItemToSave(garages)) + viewModel.markAsSaved() + if (showNotification) { + scope.launch { snackbarHostState.showSnackbar("✓ Item saved successfully!") } + } + } + + fun handleNewItemAndCamera() { + if (viewModel.hasUnsavedChanges) { + showUnsavedWarning = true + } else { + viewModel.clearFormForNewItem(garages) + if (appSettings.openCameraOnNewItem) { + if (!appSettings.hasShownCameraPreference) { + showCameraPreferenceBanner = true + onSettingsChange(appSettings.copy(hasShownCameraPreference = true)) + } + launchCameraWithPermissionCheck() + } + } + } + + LaunchedEffect( + viewModel.itemName, viewModel.modelNumber, viewModel.description, viewModel.webLink, + viewModel.condition, viewModel.functionality, viewModel.quantity, viewModel.minPrice, + viewModel.maxPrice, viewModel.weight, viewModel.sizeCategory, viewModel.dimensions, + viewModel.imageUris.size, viewModel.selectedGarageName, viewModel.selectedCabinetName, + viewModel.selectedShelfName, viewModel.selectedBoxName + ) { + viewModel.checkForChanges() + if (autoSaveEnabled && viewModel.hasUnsavedChanges && viewModel.itemName.isNotBlank()) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastAutoSaveTime > 3000) { + delay(3000) + saveItem(false) + lastAutoSaveTime = currentTime + scope.launch { snackbarHostState.showSnackbar("💾 Auto-saved", withDismissAction = true) } + } + } + } + + if (showUnsavedWarning) { + AlertDialog( + onDismissRequest = { showUnsavedWarning = false }, + icon = { Icon(Icons.Default.Warning, "Warning", tint = MaterialTheme.colorScheme.error) }, + title = { Text("Unsaved Changes", fontWeight = FontWeight.Bold) }, + text = { Text("You have unsaved changes. Do you want to save before creating a new item?") }, + confirmButton = { + Button(onClick = { + saveItem() + viewModel.clearFormForNewItem(garages) + showUnsavedWarning = false + if (appSettings.openCameraOnNewItem) { + launchCameraWithPermissionCheck() + } + }) { + Text("Save & Continue") + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton({ showUnsavedWarning = false }) { Text("Cancel") } + TextButton( + onClick = { + viewModel.clearFormForNewItem(garages) + showUnsavedWarning = false + if (appSettings.openCameraOnNewItem) { + launchCameraWithPermissionCheck() + } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Discard") + } + } + } + ) + } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + .padding(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + "Create / Edit Item", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val statusIcon = if (viewModel.hasUnsavedChanges) Icons.Default.Edit else Icons.Default.Check + val statusText = if (viewModel.hasUnsavedChanges) "Unsaved changes" else "All changes saved" + val statusColor = if (viewModel.hasUnsavedChanges) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.primary + Icon(statusIcon, statusText, tint = statusColor, modifier = Modifier.size(16.dp)) + Text(statusText, style = MaterialTheme.typography.bodySmall, color = statusColor) + } + } + OutlinedButton( + onClick = { autoSaveEnabled = !autoSaveEnabled }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = if (autoSaveEnabled) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + if (autoSaveEnabled) Icons.Default.Check else Icons.Default.Close, + null, + modifier = Modifier.size(16.dp) + ) + Text("Auto-save: ${if (autoSaveEnabled) "ON" else "OFF"}", modifier = Modifier.padding(start = 4.dp)) + } + } + + AnimatedVisibility( + visible = showCameraPreferenceBanner, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut() + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("📸 Auto-Camera Enabled", fontWeight = FontWeight.Bold) + Text( + "The camera opens automatically on 'New Item'. You can change this in Settings.", + style = MaterialTheme.typography.bodySmall + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton({ + onSettingsChange( + appSettings.copy( + openCameraOnNewItem = false, + hasShownCameraPreference = true + ) + ) + showCameraPreferenceBanner = false + }) { + Text("Turn Off") + } + TextButton({ + onSettingsChange(appSettings.copy(hasShownCameraPreference = true)) + showCameraPreferenceBanner = false + }) { + Text("Got It") + } + } + } + } + } + + ModernCard { + SectionHeader("📝 Core Details") + ModernTextField( + value = viewModel.itemName, + onValueChange = { viewModel.itemName = it }, + label = "Item Name *", + leadingIcon = Icons.Default.Edit + ) + ModernTextField( + value = viewModel.modelNumber, + onValueChange = { viewModel.modelNumber = it }, + label = "Model Number", + leadingIcon = Icons.Default.ConfirmationNumber + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.weight(1f)) { + DropdownField( + "Condition", + listOf("New", "Used", "Good", "For Parts"), + viewModel.condition + ) { viewModel.condition = it } + } + Box(Modifier.weight(1f)) { + DropdownField( + "Functionality", + listOf("Fully Functional", "Partially Functional", "Not Functional", "Needs Testing"), + viewModel.functionality + ) { viewModel.functionality = it } + } + } + ModernTextField( + value = viewModel.description, + onValueChange = { viewModel.description = it }, + label = "Description", + singleLine = false, + modifier = Modifier.height(120.dp), + leadingIcon = Icons.AutoMirrored.Filled.Notes + ) + } + + ModernCard { + SectionHeader("📍 Location") + DropdownField( + "Garage *", + garageOptions, + viewModel.selectedGarageName + ) { + viewModel.selectedGarageName = it + viewModel.selectedCabinetName = "" + viewModel.selectedShelfName = "" + viewModel.selectedBoxName = null + } + DropdownField( + "Cabinet", + cabinetOptions, + viewModel.selectedCabinetName + ) { + viewModel.selectedCabinetName = it + viewModel.selectedShelfName = "" + viewModel.selectedBoxName = null + } + DropdownField( + "Shelf", + shelfOptions, + viewModel.selectedShelfName + ) { + viewModel.selectedShelfName = it + viewModel.selectedBoxName = null + } + DropdownField( + "Box/Bin (Optional)", + listOf("None") + boxOptions, + viewModel.selectedBoxName ?: "None" + ) { + viewModel.selectedBoxName = if (it == "None") null else it + } + } + + ModernCard { + SectionHeader("📊 Attributes") + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ModernTextField( + value = viewModel.quantity, + onValueChange = { viewModel.quantity = it }, + label = "Quantity", + keyboardType = KeyboardType.Number, + modifier = Modifier.weight(1f) + ) + ModernTextField( + value = viewModel.weight, + onValueChange = { viewModel.weight = it }, + label = "Weight (lbs)", + keyboardType = KeyboardType.Decimal, + modifier = Modifier.weight(1f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ModernTextField( + value = viewModel.minPrice, + onValueChange = { viewModel.minPrice = it }, + label = "Min Price ($)", + keyboardType = KeyboardType.Decimal, + modifier = Modifier.weight(1f) + ) + ModernTextField( + value = viewModel.maxPrice, + onValueChange = { viewModel.maxPrice = it }, + label = "Max Price ($)", + keyboardType = KeyboardType.Decimal, + modifier = Modifier.weight(1f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.weight(1f)) { + DropdownField( + "Size", + listOf("Small", "Medium", "Large"), + viewModel.sizeCategory + ) { viewModel.sizeCategory = it } + } + ModernTextField( + value = viewModel.dimensions, + onValueChange = { viewModel.dimensions = it }, + label = "Dimensions (LxWxH)", + modifier = Modifier.weight(1f) + ) + } + } + + ModernCard { + ModernTextField( + value = viewModel.webLink, + onValueChange = { viewModel.webLink = it }, + label = "Web Link / URL" + ) + } + + ModernCard { + SectionHeader("📸 Images & Recognition") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + { launchCameraWithPermissionCheck() }, + Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { Text("📷 Camera") } + Button( + { galleryLauncher.launch("image/*") }, + Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { Text("📁 Upload") } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + {}, + Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { Text("📄 OCR") } + OutlinedButton( + {}, + Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { Text("🤖 AI ID") } + } + if (viewModel.imageUris.isNotEmpty()) { + Row(Modifier.horizontalScroll(rememberScrollState()).padding(top = 8.dp)) { + viewModel.imageUris.forEach { uri -> + Box(modifier = Modifier.padding(4.dp)) { + Card( + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Image( + rememberAsyncImagePainter(uri), + "Selected", + Modifier.size(120.dp) + ) + } + IconButton( + onClick = { + viewModel.imageUris.removeAt(viewModel.imageUris.indexOf(uri)) + viewModel.checkForChanges() + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .background(Color.Black.copy(0.6f), RoundedCornerShape(8.dp)) + .size(32.dp) + ) { + Icon(Icons.Default.Close, "Delete image", tint = Color.White) + } + } + } + } + } + } + } + + Card( + modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter), + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = ::handleNewItemAndCamera, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ), + shape = RoundedCornerShape(12.dp) + ) { + Text("➕ New Item") + } + Button( + onClick = { saveItem() }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + enabled = viewModel.itemName.isNotBlank() + ) { + val scale by animateFloatAsState( + if (viewModel.hasUnsavedChanges) 1.1f else 1f, + label = "s" + ) + Icon(Icons.Default.Check, "Save", modifier = Modifier.scale(scale)) + Text("Save Item", modifier = Modifier.padding(start = 4.dp)) + } + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 90.dp) + ) { data -> + Snackbar( + snackbarData = data, + shape = RoundedCornerShape(12.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } +} + +@Composable +fun ModernCard(content: @Composable ColumnScope.() -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(3.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content + ) + } +} + +@Composable +fun SectionHeader(text: String) { + Text( + text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) +} + +@Composable +fun ModernTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + leadingIcon: ImageVector? = null, + keyboardType: KeyboardType = KeyboardType.Text, + singleLine: Boolean = true +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + leadingIcon = leadingIcon?.let { { Icon(it, null) } }, + modifier = modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + singleLine = singleLine, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropdownField( + label: String, + options: List, + selectedValue: String, + onValueChange: (String) -> Unit +) { + var isExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = isExpanded, + onExpandedChange = { isExpanded = it } + ) { + OutlinedTextField( + value = selectedValue.ifEmpty { "Select..." }, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) + ExposedDropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onValueChange(option) + isExpanded = false + } + ) + } + } + } +} + +fun createImageUri(context: Context): Uri { + val imageFile = File(context.cacheDir, "${UUID.randomUUID()}.jpg") + return FileProvider.getUriForFile(context, "${context.packageName}.provider", imageFile) +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemVewModel.kt b/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemVewModel.kt new file mode 100644 index 0000000..4b0a2f5 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemVewModel.kt @@ -0,0 +1,148 @@ +package com.samuel.inventorymanager.screens + +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import java.util.UUID + +class CreateItemViewModel : ViewModel() { + + // Form fields - all mutable state + var itemName by mutableStateOf("") + var modelNumber by mutableStateOf("") + var description by mutableStateOf("") + var webLink by mutableStateOf("") + var condition by mutableStateOf("") + var functionality by mutableStateOf("") + var quantity by mutableStateOf("") + var minPrice by mutableStateOf("") + var maxPrice by mutableStateOf("") + var weight by mutableStateOf("") + var sizeCategory by mutableStateOf("") + var dimensions by mutableStateOf("") + + // Location fields + var selectedGarageName by mutableStateOf("") + var selectedCabinetName by mutableStateOf("") + var selectedShelfName by mutableStateOf("") + var selectedBoxName by mutableStateOf(null) + + // Images + val imageUris = mutableStateListOf() + + // Change tracking + var hasUnsavedChanges by mutableStateOf(false) + private set + private var lastSavedState: Int = 0 + + init { + lastSavedState = getCurrentStateHash() + } + + fun checkForChanges() { + hasUnsavedChanges = getCurrentStateHash() != lastSavedState + } + + private fun getCurrentStateHash(): Int { + return listOf( + itemName, modelNumber, description, webLink, condition, functionality, + quantity, minPrice, maxPrice, weight, sizeCategory, dimensions, + selectedGarageName, selectedCabinetName, selectedShelfName, selectedBoxName, + imageUris.size + ).hashCode() + } + + fun getItemToSave(garages: List): Item { + val garage = garages.find { it.name == selectedGarageName } + val cabinet = garage?.cabinets?.find { it.name == selectedCabinetName } + val shelf = cabinet?.shelves?.find { it.name == selectedShelfName } + val box = shelf?.boxes?.find { it.name == selectedBoxName } + + return Item( + id = UUID.randomUUID().toString(), + name = itemName, + modelNumber = modelNumber.ifBlank { null }, + description = description.ifBlank { null }, + webLink = webLink.ifBlank { null }, + condition = condition, + functionality = functionality, + garageId = garage?.id ?: "", + cabinetId = cabinet?.id ?: "", + shelfId = shelf?.id ?: "", + boxId = box?.id, + quantity = quantity.toIntOrNull() ?: 1, + minPrice = minPrice.toDoubleOrNull(), + maxPrice = maxPrice.toDoubleOrNull(), + weight = weight.toDoubleOrNull(), + sizeCategory = sizeCategory, + dimensions = dimensions.ifBlank { null }, + images = imageUris.map { it.toString() } + ) + } + + fun markAsSaved() { + lastSavedState = getCurrentStateHash() + hasUnsavedChanges = false + } + + fun clearFormForNewItem(garages: List) { + itemName = "" + modelNumber = "" + description = "" + webLink = "" + condition = "" + functionality = "" + quantity = "" + minPrice = "" + maxPrice = "" + weight = "" + sizeCategory = "" + dimensions = "" + imageUris.clear() + + // Keep location if valid, otherwise reset + if (garages.none { it.name == selectedGarageName }) { + selectedGarageName = "" + selectedCabinetName = "" + selectedShelfName = "" + selectedBoxName = null + } + + hasUnsavedChanges = false + lastSavedState = getCurrentStateHash() + } + + fun loadItemForEditing(item: Item, garages: List) { + val garage = garages.find { it.id == item.garageId } + val cabinet = garage?.cabinets?.find { it.id == item.cabinetId } + val shelf = cabinet?.shelves?.find { it.id == item.shelfId } + val box = shelf?.boxes?.find { it.id == item.boxId } + + itemName = item.name + modelNumber = item.modelNumber ?: "" + description = item.description ?: "" + webLink = item.webLink ?: "" + condition = item.condition + functionality = item.functionality + quantity = item.quantity.toString() + minPrice = item.minPrice?.toString() ?: "" + maxPrice = item.maxPrice?.toString() ?: "" + weight = item.weight?.toString() ?: "" + sizeCategory = item.sizeCategory + dimensions = item.dimensions ?: "" + + selectedGarageName = garage?.name ?: "" + selectedCabinetName = cabinet?.name ?: "" + selectedShelfName = shelf?.name ?: "" + selectedBoxName = box?.name + + imageUris.clear() + imageUris.addAll(item.images.map { Uri.parse(it) }) + + hasUnsavedChanges = false + lastSavedState = getCurrentStateHash() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/DashboardScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/DashboardScreen.kt new file mode 100644 index 0000000..80aa38f --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/DashboardScreen.kt @@ -0,0 +1,1448 @@ +package com.samuel.inventorymanager.screens + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.with +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.Diamond +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HomeWork +import androidx.compose.material.icons.filled.Inventory +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Scale +import androidx.compose.material.icons.filled.TrendingUp +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// ====================================================================== +// NAVIGATION STATE DATA CLASS +// ====================================================================== + +sealed class DashboardScreen { + object Main : DashboardScreen() + data class GarageItems(val garage: Garage) : DashboardScreen() + data class ItemDetail(val item: Item, val garage: Garage) : DashboardScreen() + data class AnalyticsDetail(val type: AnalyticsType) : DashboardScreen() +} + +enum class AnalyticsType { + CATEGORY, CONDITION, LOCATION, SIZE +} + +// ====================================================================== +// MAIN DASHBOARD COMPOSABLE +// ====================================================================== + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) +@Composable +fun DashboardScreen( + items: List, + garages: List +) { + var currentScreen by remember { mutableStateOf(DashboardScreen.Main) } + + AnimatedContent( + targetState = currentScreen, + transitionSpec = { + if (targetState is DashboardScreen.Main) { + slideInHorizontally { fullWidth -> -fullWidth } with slideOutHorizontally { fullWidth -> fullWidth } + } else { + slideInHorizontally { fullWidth -> fullWidth } with slideOutHorizontally { fullWidth -> -fullWidth } + } + }, + label = "dashboard_animation" + ) { screen -> + when (screen) { + is DashboardScreen.Main -> { + MainDashboard( + items = items, + garages = garages, + onGarageClick = { clickedGarage -> + currentScreen = DashboardScreen.GarageItems(clickedGarage) + }, + onAnalyticsClick = { analyticsType -> + currentScreen = DashboardScreen.AnalyticsDetail(analyticsType) + } + ) + } + is DashboardScreen.GarageItems -> { + val itemsInGarage = items.filter { it.garageId == screen.garage.id } + GarageItemsView( + garage = screen.garage, + items = itemsInGarage, + onBackClick = { currentScreen = DashboardScreen.Main }, + onItemClick = { clickedItem -> + currentScreen = DashboardScreen.ItemDetail(clickedItem, screen.garage) + } + ) + } + is DashboardScreen.ItemDetail -> { + ItemDetailScreen( + item = screen.item, + garage = screen.garage, + onBackClick = { + currentScreen = DashboardScreen.GarageItems(screen.garage) + } + ) + } + is DashboardScreen.AnalyticsDetail -> { + AnalyticsDetailScreen( + analyticsType = screen.type, + items = items, + garages = garages, + onBackClick = { currentScreen = DashboardScreen.Main } + ) + } + } + } +} + +// ====================================================================== +// MAIN DASHBOARD UI (CLEANED) +// ====================================================================== + +@Composable +fun MainDashboard( + items: List, + garages: List, + onGarageClick: (Garage) -> Unit, + onAnalyticsClick: (AnalyticsType) -> Unit +) { + var totalItemsExpanded by remember { mutableStateOf(false) } + var totalValueExpanded by remember { mutableStateOf(false) } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Statistics Cards + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + ExpandableStatCard( + title = "Total Items", + value = items.size.toString(), + icon = Icons.Default.Inventory, + color = MaterialTheme.colorScheme.primaryContainer, + isExpanded = totalItemsExpanded, + onToggle = { totalItemsExpanded = !totalItemsExpanded } + ) { + ItemsBreakdown(items, garages) + } + + ExpandableStatCard( + title = "Total Value", + value = "$${String.format("%.2f", items.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity })}", + icon = Icons.Default.AttachMoney, + color = MaterialTheme.colorScheme.secondaryContainer, + isExpanded = totalValueExpanded, + onToggle = { totalValueExpanded = !totalValueExpanded } + ) { + ValueBreakdown(items, garages) + } + } + } + + // Analytics Section + item { + Text( + text = "Analytics Overview", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + // Analytics Grid - 2x2 + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ClickableChartCard( + title = "By Category", + icon = Icons.Default.Category, + modifier = Modifier.weight(1f), + onClick = { onAnalyticsClick(AnalyticsType.CATEGORY) } + ) { + MiniCategoryChart(items) + } + ClickableChartCard( + title = "By Condition", + icon = Icons.Default.Diamond, + modifier = Modifier.weight(1f), + onClick = { onAnalyticsClick(AnalyticsType.CONDITION) } + ) { + MiniConditionChart(items) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ClickableChartCard( + title = "By Location", + icon = Icons.Default.LocationOn, + modifier = Modifier.weight(1f), + onClick = { onAnalyticsClick(AnalyticsType.LOCATION) } + ) { + MiniLocationChart(items, garages) + } + ClickableChartCard( + title = "By Size", + icon = Icons.Default.Scale, + modifier = Modifier.weight(1f), + onClick = { onAnalyticsClick(AnalyticsType.SIZE) } + ) { + MiniSizeChart(items) + } + } + } + } + + // Your Garages + item { + Text( + text = "Your Garages", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + item { + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(garages) { garage -> + GarageStatCard( + garageName = garage.name, + itemCount = items.count { it.garageId == garage.id }, + onClick = { onGarageClick(garage) } + ) + } + } + } + } +} + +// ====================================================================== +// ANALYTICS DETAIL SCREEN +// ====================================================================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnalyticsDetailScreen( + analyticsType: AnalyticsType, + items: List, + garages: List, + onBackClick: () -> Unit +) { + val title = when (analyticsType) { + AnalyticsType.CATEGORY -> "Category Analytics" + AnalyticsType.CONDITION -> "Condition Analytics" + AnalyticsType.LOCATION -> "Location Analytics" + AnalyticsType.SIZE -> "Size Analytics" + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + when (analyticsType) { + AnalyticsType.CATEGORY -> { + item { DetailedCategoryAnalytics(items) } + } + AnalyticsType.CONDITION -> { + item { DetailedConditionAnalytics(items) } + } + AnalyticsType.LOCATION -> { + item { DetailedLocationAnalytics(items, garages) } + } + AnalyticsType.SIZE -> { + item { DetailedSizeAnalytics(items) } + } + } + } + } +} + +// ====================================================================== +// DETAILED ANALYTICS COMPONENTS +// ====================================================================== + +@Composable +fun DetailedCategoryAnalytics(items: List) { + val categories = items.groupBy { it.sizeCategory } .mapValues { entry -> + val itemList = entry.value + Triple( + itemList.size, + itemList.sumOf { it.quantity }, + itemList.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity } + ) + } + .toList() + .sortedByDescending { it.second.first } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Large Pie Chart + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Category Distribution", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + AdvancedPieChart( + data = categories.map { it.first to it.second.first }, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) + } + } + + // Statistics Table + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Category Breakdown", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + + // Header Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Category", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.3f)) + Text("Items", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("Qty", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("Value", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.3f), textAlign = TextAlign.End) + } + Spacer(Modifier.height(8.dp)) + Divider() + Spacer(Modifier.height(8.dp)) + + categories.forEach { (category, stats) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(category, modifier = Modifier.weight(0.3f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${stats.first}", modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("${stats.second}", modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("$${String.format("%.0f", stats.third)}", modifier = Modifier.weight(0.3f), textAlign = TextAlign.End) + } + Divider() + } + } + } + + // Top Category Highlight + if (categories.isNotEmpty()) { + val top = categories.first() + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.TrendingUp, null, modifier = Modifier.size(32.dp)) + Spacer(Modifier.width(12.dp)) + Column { + Text("Top Category", style = MaterialTheme.typography.labelLarge) + Text( + text = top.first, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + } + Spacer(Modifier.height(12.dp)) + Text("${top.second.first} unique items • ${top.second.second} total quantity • $${String.format("%.2f", top.second.third)} value") + } + } + } + } +} + +@Composable +fun DetailedConditionAnalytics(items: List) { + val conditions = items.groupBy { it.condition } + .mapValues { entry -> + val itemList = entry.value + Triple( + itemList.size, + itemList.sumOf { it.quantity }, + itemList.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity } + ) + } + .toList() + .sortedByDescending { it.second.first } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Condition Distribution", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + AdvancedPieChart( + data = conditions.map { it.first to it.second.first }, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Condition Breakdown", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Condition", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.3f)) + Text("Items", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("Qty", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("Value", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.3f), textAlign = TextAlign.End) + } + Spacer(Modifier.height(8.dp)) + Divider() + Spacer(Modifier.height(8.dp)) + + conditions.forEach { (condition, stats) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(condition, modifier = Modifier.weight(0.3f)) + Text("${stats.first}", modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("${stats.second}", modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("$${String.format("%.0f", stats.third)}", modifier = Modifier.weight(0.3f), textAlign = TextAlign.End) + } + Divider() + } + } + } + } +} + +@Composable +fun DetailedLocationAnalytics(items: List, garages: List) { + val locationData = garages.map { garage -> + val garageItems = items.filter { it.garageId == garage.id } + garage.name to Triple( + garageItems.size, + garageItems.sumOf { it.quantity }, + garageItems.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity } + ) + }.filter { it.second.first > 0 } + .sortedByDescending { it.second.first } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Location Distribution", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + AdvancedBarChart( + data = locationData.map { it.first to it.second.first }, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Location Breakdown", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Location", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.3f)) + Text("Items", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("Qty", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("Value", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.3f), textAlign = TextAlign.End) + } + Spacer(Modifier.height(8.dp)) + Divider() + Spacer(Modifier.height(8.dp)) + + locationData.forEach { (location, stats) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(location, modifier = Modifier.weight(0.3f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${stats.first}", modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("${stats.second}", modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("$${String.format("%.0f", stats.third)}", modifier = Modifier.weight(0.3f), textAlign = TextAlign.End) + } + Divider() + } + } + } + } +} + +@Composable +fun DetailedSizeAnalytics(items: List) { + val sizes = items.groupBy { it.sizeCategory } + .mapValues { entry -> + val itemList = entry.value + Triple( + itemList.size, + itemList.sumOf { it.quantity }, + itemList.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity } + ) + } + .toList() + .sortedByDescending { it.second.first } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Size Distribution", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + AdvancedPieChart( + data = sizes.map { it.first to it.second.first }, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Size Breakdown", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Size", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.3f)) + Text("Items", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("Qty", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("Value", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.3f), textAlign = TextAlign.End) + } + Spacer(Modifier.height(8.dp)) + Divider() + Spacer(Modifier.height(8.dp)) + + sizes.forEach { (size, stats) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(size, modifier = Modifier.weight(0.3f)) + Text("${stats.first}", modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("${stats.second}", modifier = Modifier.weight(0.2f), textAlign = TextAlign.Center) + Text("$${String.format("%.0f", stats.third)}", modifier = Modifier.weight(0.3f), textAlign = TextAlign.End) + } + Divider() + } + } + } + } +} + +// ====================================================================== +// ADVANCED CHART COMPONENTS +// ====================================================================== + +@Composable +fun AdvancedPieChart( + data: List>, + modifier: Modifier = Modifier +) { + val total = data.sumOf { it.second }.toFloat() + val colors = listOf( + Color(0xFF6200EE), + Color(0xFF03DAC5), + Color(0xFFFF6B6B), + Color(0xFF4ECDC4), + Color(0xFFFFBE0B), + Color(0xFFFFA500), + Color(0xFF9C27B0), + Color(0xFF00BCD4) + ) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + // Pie Chart + Canvas( + modifier = Modifier + .size(200.dp) + .weight(1f) + ) { + val canvasSize = size.minDimension + val radius = canvasSize / 2.5f + val center = Offset(size.width / 2, size.height / 2) + + var startAngle = -90f + data.forEachIndexed { index, (_, value) -> + val sweepAngle = (value / total) * 360f + drawArc( + color = colors[index % colors.size], + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = true, + topLeft = Offset(center.x - radius, center.y - radius), + size = Size(radius * 2, radius * 2) + ) + startAngle += sweepAngle + } + + // Inner white circle for donut effect + drawCircle( + color = Color.White, + radius = radius * 0.5f, + center = center + ) + } + + // Legend + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + data.forEachIndexed { index, (label, value) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .size(16.dp) + .background(colors[index % colors.size], CircleShape) + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "$value items (${String.format("%.1f", (value / total) * 100)}%)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +@Composable +fun AdvancedBarChart( + data: List>, + modifier: Modifier = Modifier +) { + val maxValue = data.maxOfOrNull { it.second }?.toFloat() ?: 1f + val barColor = MaterialTheme.colorScheme.primary + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + data.forEach { (label, value) -> + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = value.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(8.dp) + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth(value / maxValue) + .height(32.dp) + .background( + barColor, + RoundedCornerShape(8.dp) + ) + ) + } + } + } + } +} + +// ====================================================================== +// MINI CHART COMPONENTS FOR DASHBOARD +// ====================================================================== + +@Composable +fun MiniCategoryChart(items: List) { + val categories = items.groupBy { it.sizeCategory } + .mapValues { it.value.size } + .toList() + .sortedByDescending { it.second } + .take(3) + + if (categories.isEmpty()) { + Text("No data", style = MaterialTheme.typography.bodySmall) + return + } + + MiniPieChart(categories) +} + +@Composable +fun MiniConditionChart(items: List) { + val conditions = items.groupBy { it.condition } + .mapValues { it.value.size } + .toList() + .take(3) + + if (conditions.isEmpty()) { + Text("No data", style = MaterialTheme.typography.bodySmall) + return + } + + MiniPieChart(conditions) +} + +@Composable +fun MiniLocationChart(items: List, garages: List) { + val locationData = garages.map { garage -> + garage.name to items.count { it.garageId == garage.id } + }.filter { it.second > 0 } + .take(3) + + if (locationData.isEmpty()) { + Text("No data", style = MaterialTheme.typography.bodySmall) + return + } + + MiniPieChart(locationData) +} + +@Composable +fun MiniSizeChart(items: List) { + val sizes = items.groupBy { it.sizeCategory } + .mapValues { it.value.size } + .toList() + .take(3) + + if (sizes.isEmpty()) { + Text("No data", style = MaterialTheme.typography.bodySmall) + return + } + + MiniPieChart(sizes) +} + +@Composable +fun MiniPieChart(data: List>) { + val total = data.sumOf { it.second }.toFloat() + val colors = listOf( + Color(0xFF6200EE), + Color(0xFF03DAC5), + Color(0xFFFF6B6B) + ) + + Canvas( + modifier = Modifier.size(80.dp) + ) { + val canvasSize = size.minDimension + val radius = canvasSize / 2 + val center = Offset(size.width / 2, size.height / 2) + + var startAngle = -90f + data.forEachIndexed { index, (_, value) -> + val sweepAngle = (value / total) * 360f + drawArc( + color = colors[index % colors.size], + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = true, + topLeft = Offset(center.x - radius, center.y - radius), + size = Size(radius * 2, radius * 2) + ) + startAngle += sweepAngle + } + } +} + +// ====================================================================== +// EXPANDABLE STAT CARD +// ====================================================================== + +@Composable +fun ExpandableStatCard( + title: String, + value: String, + icon: ImageVector, + color: Color, + isExpanded: Boolean, + onToggle: () -> Unit, + content: @Composable () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle), + colors = CardDefaults.cardColors(containerColor = color), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp)) + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + } + } + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = "Expand", + modifier = Modifier.size(28.dp) + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + Spacer(Modifier.height(12.dp)) + Divider() + Spacer(Modifier.height(12.dp)) + content() + } + } + } + } +} + +// ====================================================================== +// CLICKABLE CHART CARD +// ====================================================================== + +@Composable +fun ClickableChartCard( + title: String, + icon: ImageVector, + modifier: Modifier = Modifier, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Card( + modifier = modifier + .height(180.dp) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxSize() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Text( + text = title, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, + fontSize = 13.sp + ) + } + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + content() + } + } + } +} + +// ====================================================================== +// BREAKDOWN COMPONENTS +// ====================================================================== + +@Composable +fun ItemsBreakdown(items: List, garages: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + garages.forEach { garage -> + val garageItems = items.filter { it.garageId == garage.id } + if (garageItems.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = garage.name, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = "${garageItems.size} items", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Divider(modifier = Modifier.padding(vertical = 4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Unique Items", fontWeight = FontWeight.Bold) + Text(items.size.toString(), fontWeight = FontWeight.Bold) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Total Quantity", fontWeight = FontWeight.Bold) + Text(items.sumOf { it.quantity }.toString(), fontWeight = FontWeight.Bold) + } + } +} + +@Composable +fun ValueBreakdown(items: List, garages: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + garages.forEach { garage -> + val garageItems = items.filter { it.garageId == garage.id } + if (garageItems.isNotEmpty()) { + val garageValue = garageItems.sumOf { + ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = garage.name, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = "$${String.format("%.2f", garageValue)}", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Divider(modifier = Modifier.padding(vertical = 4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Average Item Value", fontWeight = FontWeight.Bold) + val avgValue = if (items.isNotEmpty()) + items.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 } / items.size + else 0.0 + Text("$${String.format("%.2f", avgValue)}", fontWeight = FontWeight.Bold) + } + } +} + +// ====================================================================== +// GARAGE ITEMS LIST VIEW +// ====================================================================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GarageItemsView( + garage: Garage, + items: List, + onBackClick: () -> Unit, + onItemClick: (Item) -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(garage.name) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (items.isEmpty()) { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No items found in this garage.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } else { + items(items) { item -> + CompactItemCard( + item = item, + onClick = { onItemClick(item) } + ) + } + } + } + } +} + +// ====================================================================== +// ITEM DETAIL SCREEN +// ====================================================================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemDetailScreen( + item: Item, + garage: Garage, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = item.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text( + text = item.name, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + + item { + InfoSection(title = "Basic Information") { + DetailRow("Quantity", item.quantity.toString()) + DetailRow("Condition", item.condition) + DetailRow("Functionality", item.functionality) + DetailRow("Size Category", item.sizeCategory) + item.modelNumber?.let { DetailRow("Model Number", it) } + } + } + + item { + InfoSection(title = "Location") { + DetailRow("Garage", garage.name) + } + } + + if (item.dimensions != null || item.weight != null) { + item { + InfoSection(title = "Physical Specifications") { + item.dimensions?.let { DetailRow("Dimensions", it) } + item.weight?.let { DetailRow("Weight", "${it} lbs") } + } + } + } + + val avgPrice = ((item.minPrice ?: 0.0) + (item.maxPrice ?: 0.0)) / 2 + if (avgPrice > 0) { + item { + InfoSection(title = "Pricing") { + item.minPrice?.let { + DetailRow("Min Price", "$${String.format("%.2f", it)}") + } + item.maxPrice?.let { + DetailRow("Max Price", "$${String.format("%.2f", it)}") + } + DetailRow("Est. Value", "$${String.format("%.2f", avgPrice)}") + DetailRow( + "Total Value", + "$${String.format("%.2f", avgPrice * item.quantity)}" + ) + } + } + } + + item.description?.let { desc -> + if (desc.isNotBlank()) { + item { + InfoSection(title = "Description") { + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + item.webLink?.let { link -> + if (link.isNotBlank()) { + item { + InfoSection(title = "Web Link") { + Text( + text = link, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } +} + +// ====================================================================== +// HELPER COMPOSABLES +// ====================================================================== + +@Composable +fun GarageStatCard( + garageName: String, + itemCount: Int, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .size(width = 140.dp, height = 100.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Icon( + imageVector = Icons.Default.HomeWork, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Column { + Text( + text = garageName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "$itemCount items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f) + ) + } + } + } +} + +@Composable +fun CompactItemCard( + item: Item, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Qty: ${item.quantity} • ${item.condition}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = "View details", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun InfoSection( + title: String, + content: @Composable () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(12.dp)) + content() + } + } +} + +@Composable +fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(0.4f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.6f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/DataModels.kt b/app/src/main/java/com/samuel/inventorymanager/screens/DataModels.kt new file mode 100644 index 0000000..e37e203 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/DataModels.kt @@ -0,0 +1,54 @@ +package com.samuel.inventorymanager.screens + +// --- Core Data Structures --- +data class Item( + val id: String, + val name: String, + val modelNumber: String?, + val description: String?, + val webLink: String?, + val condition: String, + val functionality: String, + val garageId: String, + val cabinetId: String, + val shelfId: String, + val boxId: String?, + val quantity: Int, + val minPrice: Double?, + val maxPrice: Double?, + val weight: Double?, + val sizeCategory: String, + val dimensions: String?, + val images: List +) + +data class Box(val id: String, val name: String) +data class Shelf(val id: String, val name: String, val boxes: List) +data class Cabinet(val id: String, val name: String, val shelves: List) +data class Garage(val id: String, val name: String, val cabinets: List) + +// --- History Tracking --- +sealed class HistoryAction { + object Added : HistoryAction() + object Updated : HistoryAction() + object Removed : HistoryAction() + data class QuantityChanged(val oldQuantity: Int, val newQuantity: Int) : HistoryAction() + data class CheckedOut(val userId: String) : HistoryAction() + data class CheckedIn(val userId: String) : HistoryAction() +} + +data class HistoryEntry( + val id: String, + val itemId: String, + val itemName: String, + val action: HistoryAction, + val description: String, + val timestamp: Long = System.currentTimeMillis() +) + +// --- App Data Bundle for Saving/Loading --- +data class AppData( + val garages: List, + val items: List, + val history: List +) \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/HelpScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/HelpScreen.kt new file mode 100644 index 0000000..948aed0 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/HelpScreen.kt @@ -0,0 +1,372 @@ +package com.samuel.inventorymanager.screens + +import android.content.Intent +import android.net.Uri +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.RocketLaunch +import androidx.compose.material.icons.filled.SdStorage +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.ShoppingBag +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun HelpScreen() { + val context = LocalContext.current + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + // --- HEADER --- + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("❓", fontSize = 56.sp) + Spacer(Modifier.height(8.dp)) + Text( + "Help & Support Center", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + "Your guide to mastering Android Inventory Pro", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // --- GETTING STARTED --- + item { + CollapsibleHelpSection( + title = "Getting Started Guide", + icon = Icons.Default.RocketLaunch, + initiallyExpanded = true // Start with this one open + ) { + HelpContentBlock( + question = "Step 1: Create Your First Location", + answer = "Go to the 'Locations' tab and tap the 'Add Garage' button. A 'Garage' can be any main area, like a 'Workshop,' 'Storage Unit,' or 'Kitchen.'" + ) + HelpContentBlock( + question = "Step 2: Build Your Hierarchy", + answer = "Inside your new Garage, you can add Cabinets. Inside Cabinets, add Shelves. Inside Shelves, add Boxes. This structure helps you know exactly where everything is." + ) + HelpContentBlock( + question = "Step 3: Add Your First Item", + answer = "Navigate to the 'Items' tab and tap 'New'. Fill in the details. The most important fields are Item Name and its Location (Garage)." + ) + HelpContentBlock( + question = "Step 4: Add Photos", + answer = "While editing an item, tap 'Camera' to take a photo or 'Upload' to select one from your device. A picture is worth a thousand words!" + ) + HelpContentBlock( + question = "Step 5: Find Your Items", + answer = "Use the 'Search' tab to instantly find anything you've added. For a complete spreadsheet-like view of all your items, visit the 'Overview' tab." + ) + } + } + + // --- MANAGING ITEMS --- + item { + CollapsibleHelpSection( + title = "Managing Your Items", + icon = Icons.Default.ShoppingBag + ) { + HelpContentBlock( + question = "How does Auto-Save work?", + answer = "The app automatically saves your changes a few seconds after you stop typing in the 'Items' form. A '💾 Auto-saved' message will briefly appear. You can disable this in 'Settings' > 'Data Management' if you prefer to save manually." + ) + HelpContentBlock( + question = "What are OCR and AI?", + answer = "These are powerful tools to help you add items faster. After taking a picture:\n• OCR scans the image for text (like a model number).\n• AI analyzes the image and tries to identify the item, suggesting a name and description for you." + ) + HelpContentBlock( + question = "How do I add custom options for Condition or Functionality?", + answer = "In the 'Items' tab, next to the 'Item Condition' or 'Functionality' dropdowns, tap the '+ Add' button. This lets you create your own custom options (e.g., 'Slightly Scratched' or 'Needs Batteries') that will be saved for future use." + ) + } + } + + // --- DATA & BACKUP --- + item { + CollapsibleHelpSection( + title = "Data, Backup & Sync", + icon = Icons.Default.SdStorage + ) { + HelpContentBlock( + question = "Where is my data stored?", + answer = "Your inventory data is stored locally in the app's private storage on your device. This means it works offline and is completely private. For safety, it is highly recommended you create regular backups." + ) + HelpContentBlock( + question = "How do I back up and restore my data?", + answer = "Go to 'Settings' > 'Data Management'.\n• Export Data: This saves a complete copy of your inventory to a single '.json' file in your device's Downloads folder.\n• Import Data: This lets you load an inventory from a file. Warning: Importing will overwrite all of your current data.\n• Backup to Google Drive: Sign in to your Google account and save your backup file directly to a private folder in Google Drive." + ) + HelpContentBlock( + question = "How do I sync with the computer app?", + answer = "The 'Sync' tab connects your device to our companion desktop app.\n1. Tap 'Generate' on your phone to get a Sync Key.\n2. Enter this key into the computer app.\n3. Enter the key from the computer app into your phone and tap 'Pair'." + ) + } + } + + // --- ADVANCED SETTINGS --- + item { + CollapsibleHelpSection( + title = "Advanced Settings & APIs", + icon = Icons.Default.Settings + ) { + HelpContentBlock( + question = "What are API Keys and do I need them?", + answer = "API Keys are special codes that let this app connect to powerful online services for OCR and AI. The app works perfectly fine without them using its free, built-in tools. However, adding keys can provide even more accurate results." + ) + HelpContentBlock( + question = "How does the Priority Fallback system work?", + answer = "In the OCR and AI settings, you can reorder the list of services. When you use a feature, the app tries the first service. If it fails, it automatically 'falls back' to the next one, ensuring you always get a result from the best available service." + ) + } + } + + // --- APP INFO AND CONTACT CARD --- + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(2.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + // App Info Section + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Info, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(12.dp)) + Text("App Information", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + InfoRow("Version:", "2.0 (Native)") + InfoRow("Platform:", "Android (Jetpack Compose)") + InfoRow("Data Storage:", "Local Device Storage") + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // Contact Section + ContactRow( + icon = Icons.Default.Code, + title = "GitHub of Parminder", + subtitle = "github.com/JohnJackson12", + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/JohnJackson12")) + context.startActivity(intent) + } + ) + Spacer(Modifier.height(16.dp)) + ContactRow( + icon = Icons.Default.Code, + title = "GitHub of Samuel", + subtitle = "github.com/SamS34", + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/SamS34")) + context.startActivity(intent) + } + ) + Spacer(Modifier.height(16.dp)) + ContactRow( + icon = Icons.Default.Email, + title = "Email of Parminder", + subtitle = "parminder.nz@gmail.com", + onClick = { + val intent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:parminder.nz@gmail.com") + } + context.startActivity(intent) + } + ) + Spacer(Modifier.height(16.dp)) + ContactRow( + icon = Icons.Default.Email, + title = "Email of Samuel", + subtitle = "sam.of.s34@gmail.com", + onClick = { + val intent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:sam.of.s34@gmail.com") + } + context.startActivity(intent) + } + ) + } + } + } + } +} + + +// ====================================================================== +// HELPER COMPOSABLES +// These are the reusable building blocks that make the screen work! ✨ +// ====================================================================== + +/** + * A reusable, collapsible Card for displaying FAQ-style help content. + */ +@Composable +private fun CollapsibleHelpSection( + title: String, + icon: ImageVector, + initiallyExpanded: Boolean = false, + content: @Composable ColumnScope.() -> Unit +) { + var expanded by remember { mutableStateOf(initiallyExpanded) } + val rotationAngle by animateFloatAsState(targetValue = if (expanded) 180f else 0f, label = "rotation") + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(2.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + modifier = Modifier.rotate(rotationAngle) + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Divider() + Spacer(Modifier.height(4.dp)) + content() + } + } + } + } +} + + +/** + * Formats a question and answer block within a help section. + */ +@Composable +private fun HelpContentBlock(question: String, answer: String) { + Column { + Text( + text = question, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(4.dp)) + Text( + text = answer, + style = MaterialTheme.typography.bodyLarge, + lineHeight = 24.sp + ) + } +} + +/** + * Displays a clickable row for contact information. + */ +@Composable +private fun ContactRow( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(16.dp)) + Column { + Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text( + subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + } + } +} + + +/** + * Displays a simple "Label: Value" row for app information. + */ +@Composable +private fun InfoRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(0.4f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(0.6f), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/HistoryScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/HistoryScreen.kt new file mode 100644 index 0000000..27bb1fd --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/HistoryScreen.kt @@ -0,0 +1,350 @@ +package com.samuel.inventorymanager.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.HistoryToggleOff +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HistoryScreen( + history: List, + items: List, + onItemClick: (Item) -> Unit, + onClearHistory: () -> Unit +) { + var searchQuery by remember { mutableStateOf("") } + var selectedActionType by remember { mutableStateOf(null) } + + // Filter history based on search and selected action type + val filteredHistory = remember(searchQuery, selectedActionType, history) { + history.filter { entry -> + val matchesAction = selectedActionType == null || + when (entry.action) { + is HistoryAction.Added -> selectedActionType == ActionType.ADDED + is HistoryAction.Updated -> selectedActionType == ActionType.UPDATED + is HistoryAction.Removed -> selectedActionType == ActionType.REMOVED + is HistoryAction.QuantityChanged -> selectedActionType == ActionType.QUANTITY_CHANGED + is HistoryAction.CheckedOut -> selectedActionType == ActionType.CHECKED_OUT + is HistoryAction.CheckedIn -> selectedActionType == ActionType.CHECKED_IN + } + + val matchesSearch = searchQuery.isBlank() || + entry.itemName.contains(searchQuery, ignoreCase = true) || + entry.description.contains(searchQuery, ignoreCase = true) + + matchesAction && matchesSearch + } + } + + Column(modifier = Modifier.fillMaxSize()) { + HistoryHeader( + searchQuery = searchQuery, + onSearchQueryChange = { searchQuery = it }, + selectedActionType = selectedActionType, + onActionSelect = { actionType -> + selectedActionType = if (selectedActionType == actionType) null else actionType + }, + onClearHistory = onClearHistory + ) + + if (filteredHistory.isEmpty()) { + EmptyHistoryState() + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(filteredHistory, key = { it.id }) { entry -> + HistoryItemCard( + entry = entry, + onClick = { + items.find { it.id == entry.itemId }?.let { item -> + onItemClick(item) + } + } + ) + } + } + } + } +} + +// Helper enum to simplify filtering +enum class ActionType { + ADDED, UPDATED, REMOVED, QUANTITY_CHANGED, CHECKED_OUT, CHECKED_IN +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HistoryHeader( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + selectedActionType: ActionType?, + onActionSelect: (ActionType) -> Unit, + onClearHistory: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shadowElevation = 4.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search by item name or change...") }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton({ onSearchQueryChange("") }) { + Icon(Icons.Default.Clear, "Clear") + } + } + }, + shape = RoundedCornerShape(12.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f) + ) { + FilterChip( + selected = selectedActionType == ActionType.ADDED, + onClick = { onActionSelect(ActionType.ADDED) }, + label = { Text("Added") }, + leadingIcon = { + Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)) + } + ) + FilterChip( + selected = selectedActionType == ActionType.UPDATED, + onClick = { onActionSelect(ActionType.UPDATED) }, + label = { Text("Updated") }, + leadingIcon = { + Icon(Icons.Default.Edit, null, modifier = Modifier.size(18.dp)) + } + ) + FilterChip( + selected = selectedActionType == ActionType.REMOVED, + onClick = { onActionSelect(ActionType.REMOVED) }, + label = { Text("Removed") }, + leadingIcon = { + Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)) + } + ) + } + IconButton(onClick = onClearHistory) { + Icon( + Icons.Default.DeleteForever, + "Clear History", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} + +@Composable +private fun HistoryItemCard(entry: HistoryEntry, onClick: () -> Unit) { + val actionDetails = when (entry.action) { + is HistoryAction.Added -> ActionDetails( + Icons.Default.AddCircle, + "Added", + MaterialTheme.colorScheme.primary + ) + is HistoryAction.Updated -> ActionDetails( + Icons.Default.Edit, + "Updated", + Color(0xFFF59E0B) + ) + is HistoryAction.Removed -> ActionDetails( + Icons.Default.Delete, + "Removed", + MaterialTheme.colorScheme.error + ) + is HistoryAction.QuantityChanged -> { + val qtyChange = entry.action as HistoryAction.QuantityChanged + ActionDetails( + Icons.Default.SwapVert, + "Qty: ${qtyChange.oldQuantity} → ${qtyChange.newQuantity}", + Color(0xFF8B5CF6) + ) + } + is HistoryAction.CheckedOut -> { + val checkout = entry.action as HistoryAction.CheckedOut + ActionDetails( + Icons.Default.Logout, + "Checked Out (${checkout.userId})", + Color(0xFFEF4444) + ) + } + is HistoryAction.CheckedIn -> { + val checkin = entry.action as HistoryAction.CheckedIn + ActionDetails( + Icons.Default.Login, + "Checked In (${checkin.userId})", + Color(0xFF10B981) + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = actionDetails.icon, + contentDescription = actionDetails.label, + modifier = Modifier.size(32.dp), + tint = actionDetails.color + ) + + Spacer(Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.itemName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = entry.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = actionDetails.label, + style = MaterialTheme.typography.bodySmall, + color = actionDetails.color, + fontWeight = FontWeight.Medium + ) + } + + Spacer(Modifier.width(8.dp)) + + Text( + text = formatTimestamp(entry.timestamp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} + +private data class ActionDetails( + val icon: ImageVector, + val label: String, + val color: Color +) + +@Composable +private fun EmptyHistoryState() { + Box( + modifier = Modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + Icons.Default.HistoryToggleOff, + "No History", + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + Text( + "No History Yet", + style = MaterialTheme.typography.headlineSmall + ) + Text( + "Changes you make to items and locations will appear here.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } +} + +private fun formatTimestamp(timestamp: Long): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + + return when { + diff < 60_000 -> "Just now" + diff < 3600_000 -> "${diff / 60_000}m ago" + diff < 86400_000 -> "${diff / 3600_000}h ago" + diff < 604800_000 -> "${diff / 86400_000}d ago" + else -> { + val format = SimpleDateFormat("MMM d", Locale.getDefault()) + format.format(Date(timestamp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/ImagesScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/ImagesScreen.kt new file mode 100644 index 0000000..a8c4af9 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/ImagesScreen.kt @@ -0,0 +1,167 @@ +package com.samuel.inventorymanager.screens + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ImageSearch +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter + +// We create a simple data class to make the logic cleaner. +// It bundles an image with the item it belongs to. +data class ImageEntry( + val item: Item, + val imageUrl: String +) + +@Composable +fun ImagesScreen( + items: List, + onItemClick: (Item) -> Unit // This function will trigger the navigation +) { + // We first gather all images from all items into a single, flat list. + val allImages = items.flatMap { item -> + item.images.map { imageUrl -> + ImageEntry(item = item, imageUrl = imageUrl) + } + } + + if (allImages.isEmpty()) { + // Show a helpful message if no images have been added yet. + EmptyState() + } else { + // Display the images in a responsive, vertical grid. + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), // This makes the grid look good on any screen size! + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(allImages) { imageEntry -> + ImageCard( + imageEntry = imageEntry, + onClick = { + // When an image is clicked, we call the navigation function + // with the item that the image belongs to. + onItemClick(imageEntry.item) + } + ) + } + } + } +} + + +// ====================================================================== +// HELPER COMPOSABLES +// These are the building blocks that make the screen look polished. ✨ +// ====================================================================== + +@Composable +private fun ImageCard( + imageEntry: ImageEntry, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .aspectRatio(1f) // This makes the card a perfect square + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(4.dp), + shape = MaterialTheme.shapes.medium + ) { + Box(modifier = Modifier.fillMaxSize()) { + // The Image itself, which fills the entire card + Image( + painter = rememberAsyncImagePainter(model = Uri.parse(imageEntry.imageUrl)), + contentDescription = imageEntry.item.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop // This ensures the image fills the square without stretching + ) + + // A semi-transparent overlay at the bottom to make the text readable + Box( + modifier = Modifier.run { + fillMaxWidth() + .fillMaxHeight(0.4f) + .background( + brush = androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.8f)) + ) + ) + .align(Alignment.BottomCenter) + } + ) + + // The name of the item the image belongs to + Text( + text = imageEntry.item.name, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), + color = Color.White, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun EmptyState() { + Box( + modifier = Modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.ImageSearch, + contentDescription = "No Images", + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + Text( + text = "No Images Found", + style = MaterialTheme.typography.headlineSmall + ) + Text( + text = "Add items with pictures using the camera or upload button on the 'Create Item' screen.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/LocationsScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/LocationsScreen.kt new file mode 100644 index 0000000..0619484 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/LocationsScreen.kt @@ -0,0 +1,258 @@ +package com.samuel.inventorymanager.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ViewList +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HomeWork +import androidx.compose.material.icons.filled.Inventory +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.Kitchen +import androidx.compose.material.icons.filled.LocationCity +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.util.Locale + +@Composable +fun LocationsScreen( + garages: List, + items: List, + onAddGarage: () -> Unit, + onAddCabinet: (garageId: String) -> Unit, + onAddShelf: (cabinetId: String) -> Unit, + onAddBox: (shelfId: String) -> Unit, + onRenameLocation: (id: String, oldName: String, type: String) -> Unit, + onDeleteLocation: (id: String, name: String, type: String) -> Unit +) { + Scaffold( + floatingActionButton = { + FloatingActionButton(onClick = onAddGarage) { + Icon(Icons.Default.Add, contentDescription = "Add Garage") + } + } + ) { paddingValues -> + if (garages.isEmpty()) { + EmptyState(onAddGarage, modifier = Modifier.padding(paddingValues)) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(garages, key = { it.id }) { garage -> + val itemsInGarage = items.filter { it.garageId == garage.id } + GarageSection( + garage = garage, + itemsInGarage = itemsInGarage, + onAddCabinet = { onAddCabinet(garage.id) }, + onAddShelf = onAddShelf, + onAddBox = onAddBox, + onRenameLocation = onRenameLocation, + onDeleteLocation = onDeleteLocation + ) + } + } + } + } +} + +// ====================================================================== +// SECTION COMPOSABLES +// ====================================================================== + +@Composable +private fun GarageSection(garage: Garage, itemsInGarage: List, onAddCabinet: () -> Unit, onAddShelf: (cabinetId: String) -> Unit, onAddBox: (shelfId: String) -> Unit, onRenameLocation: (id: String, oldName: String, type: String) -> Unit, onDeleteLocation: (id: String, name: String, type: String) -> Unit) { + var isExpanded by remember { mutableStateOf(false) } + val totalValue = itemsInGarage.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity } + + Card(elevation = CardDefaults.cardElevation(4.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))) { + Column { + SectionHeader( + title = garage.name, icon = Icons.Default.HomeWork, isExpanded = isExpanded, + onToggleExpand = { isExpanded = !isExpanded }, + onAddClick = onAddCabinet, + onRenameClick = { onRenameLocation(garage.id, garage.name, "garage") }, + onDeleteClick = { onDeleteLocation(garage.id, garage.name, "garage") } + ) { + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInGarage.size} items") + StatChip(icon = Icons.Default.AttachMoney, text = "$${String.format(Locale.US, "%.2f", totalValue)}") + } + AnimatedVisibility(visible = isExpanded) { + Column(modifier = Modifier.padding(start = 24.dp, end = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (garage.cabinets.isEmpty()) { + EmptyChildState("No cabinets here. Tap '+ Add' to create one.") + } else { + garage.cabinets.forEach { cabinet -> + val itemsInCabinet = itemsInGarage.filter { it.cabinetId == cabinet.id } + CabinetSection(cabinet, itemsInCabinet, { onAddShelf(cabinet.id) }, onAddBox, onRenameLocation, onDeleteLocation) + } + } + } + } + } + } +} + +@Composable +private fun CabinetSection(cabinet: Cabinet, itemsInCabinet: List, onAddShelf: () -> Unit, onAddBox: (shelfId: String) -> Unit, onRenameLocation: (id: String, oldName: String, type: String) -> Unit, onDeleteLocation: (id: String, name: String, type: String) -> Unit) { + var isExpanded by remember { mutableStateOf(false) } + Surface(shape = MaterialTheme.shapes.medium, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))) { + Column { + SectionHeader( + title = cabinet.name, icon = Icons.Default.Kitchen, isExpanded = isExpanded, + onToggleExpand = { isExpanded = !isExpanded }, + onAddClick = onAddShelf, + onRenameClick = { onRenameLocation(cabinet.id, cabinet.name, "cabinet") }, + onDeleteClick = { onDeleteLocation(cabinet.id, cabinet.name, "cabinet") } + ) { + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInCabinet.size} items", small = true) + } + AnimatedVisibility(visible = isExpanded) { + Column(modifier = Modifier.padding(start = 24.dp, end = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (cabinet.shelves.isEmpty()) { + EmptyChildState("No shelves in this cabinet.") + } else { + cabinet.shelves.forEach { shelf -> + val itemsInShelf = itemsInCabinet.filter { it.shelfId == shelf.id } + ShelfSection(shelf, itemsInShelf, { onAddBox(shelf.id) }, onRenameLocation, onDeleteLocation) + } + } + } + } + } + } +} + +@Composable +private fun ShelfSection(shelf: Shelf, itemsInShelf: List, onAddBox: () -> Unit, onRenameLocation: (id: String, oldName: String, type: String) -> Unit, onDeleteLocation: (id: String, name: String, type: String) -> Unit) { + var isExpanded by remember { mutableStateOf(false) } + Surface(shape = MaterialTheme.shapes.medium, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))) { + Column { + SectionHeader( + title = shelf.name, icon = Icons.AutoMirrored.Filled.ViewList, isExpanded = isExpanded, + onToggleExpand = { isExpanded = !isExpanded }, + onAddClick = onAddBox, + onRenameClick = { onRenameLocation(shelf.id, shelf.name, "shelf") }, + onDeleteClick = { onDeleteLocation(shelf.id, shelf.name, "shelf") } + ) { + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInShelf.size} items", small = true) + } + AnimatedVisibility(visible = isExpanded) { + Column(modifier = Modifier.padding(start = 24.dp, end = 8.dp, bottom = 8.dp)) { + if (shelf.boxes.isEmpty()) { + EmptyChildState("No boxes on this shelf.") + } else { + shelf.boxes.forEach { box -> + val itemsInBox = itemsInShelf.filter { it.boxId == box.id } + BoxItem(box = box, itemCount = itemsInBox.size) + } + } + } + } + } + } +} + + +@Composable +private fun SectionHeader(title: String, icon: ImageVector, isExpanded: Boolean, onToggleExpand: () -> Unit, onAddClick: () -> Unit, onRenameClick: () -> Unit, onDeleteClick: () -> Unit, statsContent: @Composable RowScope.() -> Unit) { + var showMenu by remember { mutableStateOf(false) } + Row(modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { statsContent() } + } + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onAddClick) { Icon(Icons.Default.Add, "+ Add") } + Box { + IconButton(onClick = { showMenu = true }) { Icon(Icons.Default.MoreVert, "More Options") } + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem(text = { Text("Rename") }, onClick = { showMenu = false; onRenameClick() }) + DropdownMenuItem(text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, onClick = { showMenu = false; onDeleteClick() }) + } + } + Icon(imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, "Toggle Section") + } + } +} + +@Composable +private fun StatChip(icon: ImageVector, text: String, small: Boolean = false) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, modifier = Modifier.size(if (small) 12.dp else 14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(4.dp)) + Text(text, style = if (small) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun BoxItem(box: Box, itemCount: Int) { + Row(Modifier.fillMaxWidth().padding(start = 8.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text("└─", color = MaterialTheme.colorScheme.outline); Spacer(Modifier.width(4.dp)) + Icon(Icons.Default.Inventory, null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(8.dp)) + Text(box.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text("$itemCount items", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun EmptyState(onAddGarage: () -> Unit, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Icon(Icons.Default.LocationCity, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)) + Text("No Locations Created Yet", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 16.dp)) + Text("Get started by adding your first garage.", textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp)) + Spacer(Modifier.height(24.dp)) + Button(onClick = onAddGarage) { Icon(Icons.Default.Add, "Add Garage"); Spacer(Modifier.width(8.dp)); Text("Add Your First Garage") } + } +} + +@Composable +private fun EmptyChildState(message: String) { + Text(message, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(16.dp).fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant) +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/MainAppScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/MainAppScreen.kt new file mode 100644 index 0000000..52574d6 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/MainAppScreen.kt @@ -0,0 +1,653 @@ +package com.samuel.inventorymanager.screens + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.gson.Gson +import com.samuel.inventorymanager.data.AppSettings +import com.samuel.inventorymanager.ui.theme.AppThemeType +import com.samuel.inventorymanager.ui.theme.InventoryManagerTheme +import kotlinx.coroutines.launch +import java.io.File +import java.io.InputStreamReader + +// ======================================================================================== +// SCREEN NAVIGATION DEFINITIONS +// ======================================================================================== + +enum class Screen { + Dashboard, Items, Locations, Search, + Overview, Images, History, Settings, Sync, Help, + CreateItem +} + +data class NavGridItem( + val title: String, + val icon: ImageVector, + val screen: Screen +) + +val navigationItems = listOf( + NavGridItem("Dashboard", Icons.Default.Dashboard, Screen.Dashboard), + NavGridItem("Items", Icons.Default.Inventory2, Screen.CreateItem), + NavGridItem("Locations", Icons.Default.LocationOn, Screen.Locations), + NavGridItem("Search", Icons.Default.Search, Screen.Search), + NavGridItem("Overview", Icons.AutoMirrored.Filled.List, Screen.Overview), + NavGridItem("Images", Icons.Default.Image, Screen.Images), + NavGridItem("History", Icons.Default.History, Screen.History), + NavGridItem("Settings", Icons.Default.Settings, Screen.Settings), + NavGridItem("Sync", Icons.Default.Sync, Screen.Sync), + NavGridItem("Help", Icons.AutoMirrored.Filled.HelpOutline, Screen.Help) +) + +sealed class DialogState { + object Closed : DialogState() + object AddGarage : DialogState() + data class AddCabinet(val garageId: String) : DialogState() + data class AddShelf(val cabinetId: String) : DialogState() + data class AddBox(val shelfId: String) : DialogState() + data class RenameLocation(val id: String, val oldName: String, val type: String) : DialogState() + data class DeleteLocation(val id: String, val name: String, val type: String) : DialogState() + object ClearHistory : DialogState() +} + +val LocalAppSettings = compositionLocalOf { AppSettings() } +val LocalOnSettingsChange = compositionLocalOf<(AppSettings) -> Unit> { {} } + +// ======================================================================================== +// MAIN APPLICATION SCREEN +// ======================================================================================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainAppScreen() { + val context = LocalContext.current + var appSettings by remember { mutableStateOf(AppSettings()) } + + // Load settings on first launch + LaunchedEffect(Unit) { + appSettings = loadSettingsFromFile(context) + } + + val onSettingsChange: (AppSettings) -> Unit = { newSettings -> + appSettings = newSettings + saveSettingsToFile(context, newSettings) + } + + // Determine theme type based on appSettings + val themeType = when { + appSettings.customTheme != null -> AppThemeType.LIGHT // Default to light for custom + else -> AppThemeType.LIGHT + } + + val fontScale = appSettings.customTheme?.fontSizeScale ?: 1.0f + + InventoryManagerTheme( + themeType = themeType, + fontScale = fontScale + ) { + var currentScreen by remember { mutableStateOf(Screen.Dashboard) } + val garages = remember { mutableStateListOf() } + val items = remember { mutableStateListOf() } + val history = remember { mutableStateListOf() } + var dialogState by remember { mutableStateOf(DialogState.Closed) } + + val createItemViewModel: CreateItemViewModel = viewModel() + + LaunchedEffect(Unit) { + loadDataFromFile(context, garages, items, history) + } + + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() + + val onSaveItem: (Item) -> Unit = { newItem -> + val index = items.indexOfFirst { it.id == newItem.id } + val newHistoryEntry: HistoryEntry + if (index != -1) { + newHistoryEntry = HistoryEntry( + id = "hist_${System.currentTimeMillis()}", + itemId = newItem.id, + itemName = newItem.name, + action = HistoryAction.Updated, + description = "Item details were modified." + ) + items[index] = newItem + } else { + newHistoryEntry = HistoryEntry( + id = "hist_${System.currentTimeMillis()}", + itemId = newItem.id, + itemName = newItem.name, + action = HistoryAction.Added, + description = "A new item was created." + ) + items.add(newItem) + } + history.add(0, newHistoryEntry) + saveDataToFile(context, AppData(garages.toList(), items.toList(), history.toList())) + } + + val onClearHistory = { dialogState = DialogState.ClearHistory } + + HandleDialogs( + dialogState = dialogState, + garages = garages, + onClearHistory = { + history.clear() + dialogState = DialogState.Closed + saveDataToFile(context, AppData(garages.toList(), items.toList(), history.toList())) + }, + onDismiss = { + dialogState = DialogState.Closed + saveDataToFile(context, AppData(garages.toList(), items.toList(), history.toList())) + } + ) + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Inventory Manager", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 16.dp) + ) + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(navigationItems) { item -> + GridNavigationButton(item, currentScreen == item.screen) { + currentScreen = item.screen + scope.launch { drawerState.close() } + } + } + } + } + } + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + currentScreen.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton({ scope.launch { drawerState.open() } }) { + Icon(Icons.Default.Menu, "Menu") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + }, + bottomBar = { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.LocationOn, null) }, + label = { Text("Locations") }, + selected = currentScreen == Screen.Locations, + onClick = { currentScreen = Screen.Locations } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Add, null) }, + label = { Text("Add Item") }, + selected = currentScreen == Screen.CreateItem, + onClick = { + createItemViewModel.clearFormForNewItem(garages) + currentScreen = Screen.CreateItem + } + ) + NavigationBarItem( + icon = { Icon(Icons.AutoMirrored.Filled.List, null) }, + label = { Text("Overview") }, + selected = currentScreen == Screen.Overview, + onClick = { currentScreen = Screen.Overview } + ) + } + } + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + when (currentScreen) { + Screen.Dashboard -> DashboardScreen(items, garages) + Screen.Locations -> LocationsScreen( + garages = garages, + items = items, + onAddGarage = { dialogState = DialogState.AddGarage }, + onAddCabinet = { gid -> dialogState = DialogState.AddCabinet(gid) }, + onAddShelf = { cid -> dialogState = DialogState.AddShelf(cid) }, + onAddBox = { sid -> dialogState = DialogState.AddBox(sid) }, + onRenameLocation = { id, old, type -> + dialogState = DialogState.RenameLocation(id, old, type) + }, + onDeleteLocation = { id, name, type -> + dialogState = DialogState.DeleteLocation(id, name, type) + } + ) + Screen.Items, Screen.CreateItem -> CreateItemScreen( + garages = garages, + onSaveItem = onSaveItem, + viewModel = createItemViewModel, + appSettings = appSettings, + onSettingsChange = onSettingsChange + ) + Screen.Overview -> OverviewScreen(items, garages) + Screen.Search -> SearchScreen(items, garages) + Screen.Settings -> SettingsScreen( + currentSettings = appSettings, + onSettingsChange = onSettingsChange + ) + Screen.Help -> HelpScreen() + Screen.Images -> ImagesScreen(items) { item -> + createItemViewModel.loadItemForEditing(item, garages) + currentScreen = Screen.CreateItem + } + Screen.History -> HistoryScreen( + history = history, + items = items, + onItemClick = { targetItem -> + createItemViewModel.loadItemForEditing(targetItem, garages) + currentScreen = Screen.CreateItem + }, + onClearHistory = onClearHistory + ) + else -> PlaceholderScreen(screenName = currentScreen.name) + } + } + } + } + } +} + +// ======================================================================================== +// HELPER COMPOSABLES +// ======================================================================================== + +@Composable +private fun GridNavigationButton(item: NavGridItem, isSelected: Boolean, onClick: () -> Unit) { + Card( + modifier = Modifier + .aspectRatio(1f) + .clickable(onClick = onClick), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = item.icon, + contentDescription = item.title, + tint = if (isSelected) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(4.dp)) + Text( + text = item.title, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun PlaceholderScreen(screenName: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "$screenName Screen", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + "Coming Soon", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HandleDialogs( + dialogState: DialogState, + garages: MutableList, + onClearHistory: () -> Unit, + onDismiss: () -> Unit +) { + when (val state = dialogState) { + is DialogState.Closed -> {} + DialogState.AddGarage -> AddLocationDialog("Create New Garage", onDismiss) { name -> + garages.add(Garage("g_${System.currentTimeMillis()}", name, emptyList())) + } + is DialogState.AddCabinet -> AddLocationDialog("Add Cabinet", onDismiss) { name -> + val i = garages.indexOfFirst { it.id == state.garageId } + if (i != -1) { + val g = garages[i] + garages[i] = g.copy( + cabinets = g.cabinets + Cabinet("c_${System.currentTimeMillis()}", name, emptyList()) + ) + } + } + is DialogState.AddShelf -> AddLocationDialog("Add Shelf", onDismiss) { name -> + for ((gi, g) in garages.withIndex()) { + val ci = g.cabinets.indexOfFirst { it.id == state.cabinetId } + if (ci != -1) { + val c = g.cabinets[ci] + val newShelf = Shelf("s_${System.currentTimeMillis()}", name, emptyList()) + garages[gi] = g.copy( + cabinets = g.cabinets.toMutableList().also { cabinetList -> + cabinetList[ci] = c.copy(shelves = c.shelves + newShelf) + } + ) + break + } + } + } + is DialogState.AddBox -> AddLocationDialog("Add Box/Bin", onDismiss) { name -> + run loop@{ + for ((gi, g) in garages.withIndex()) { + for ((ci, c) in g.cabinets.withIndex()) { + val si = c.shelves.indexOfFirst { it.id == state.shelfId } + if (si != -1) { + val s = c.shelves[si] + val newBox = Box("b_${System.currentTimeMillis()}", name) + garages[gi] = g.copy( + cabinets = g.cabinets.toMutableList().also { cabinetList -> + cabinetList[ci] = c.copy( + shelves = c.shelves.toMutableList().also { shelfList -> + shelfList[si] = s.copy(boxes = s.boxes + newBox) + } + ) + } + ) + return@loop + } + } + } + } + } + is DialogState.RenameLocation -> { + RenameDialog(state, onDismiss) { /* Rename logic */ } + } + is DialogState.DeleteLocation -> { + DeleteConfirmDialog(state, onDismiss) { /* Delete logic */ } + } + is DialogState.ClearHistory -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Clear History?") }, + text = { Text("Are you sure you want to clear all history? This cannot be undone.") }, + confirmButton = { + Button( + onClick = onClearHistory, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Clear All") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddLocationDialog(title: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var text by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { newText -> text = newText }, + label = { Text("Location Name") } + ) + }, + confirmButton = { + Button( + onClick = { + if (text.isNotBlank()) onConfirm(text) + onDismiss() + } + ) { + Text("Add") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RenameDialog( + state: DialogState.RenameLocation, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var text by remember { mutableStateOf(state.oldName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename ${state.type.replaceFirstChar { it.uppercase() }}") }, + text = { + OutlinedTextField( + value = text, + onValueChange = { newText -> text = newText }, + label = { Text("New Name") } + ) + }, + confirmButton = { + Button( + onClick = { + if (text.isNotBlank()) onConfirm(text) + onDismiss() + } + ) { + Text("Rename") + } + }, + dismissButton = { + TextButton(onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun DeleteConfirmDialog( + state: DialogState.DeleteLocation, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete ${state.type.replaceFirstChar { it.uppercase() }}?") }, + text = { + Text("Are you sure you want to delete '${state.name}'? This cannot be undone.") + }, + confirmButton = { + Button( + onClick = { + onConfirm() + onDismiss() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onDismiss) { + Text("Cancel") + } + } + ) +} + +// ======================================================================================== +// DATA PERSISTENCE FUNCTIONS +// ======================================================================================== + +private fun saveDataToFile(context: Context, appData: AppData) { + try { + context.openFileOutput("inventory.json", Context.MODE_PRIVATE).use { + it.write(Gson().toJson(appData).toByteArray()) + } + } catch (e: Exception) { + e.printStackTrace() + } +} + +private fun loadDataFromFile( + context: Context, + garages: MutableList, + items: MutableList, + history: MutableList +) { + val file = File(context.filesDir, "inventory.json") + if (!file.exists()) return + try { + context.openFileInput("inventory.json").use { stream -> + InputStreamReader(stream).use { reader -> + val data = Gson().fromJson(reader, AppData::class.java) + garages.clear() + items.clear() + history.clear() + garages.addAll(data.garages) + items.addAll(data.items) + history.addAll(data.history) + } + } + } catch (_: Exception) { + // Failed to load data, starting fresh + } +} + +private fun saveSettingsToFile(context: Context, settings: AppSettings) { + try { + context.openFileOutput("app_settings.json", Context.MODE_PRIVATE).use { + it.write(Gson().toJson(settings).toByteArray()) + } + } catch (e: Exception) { + e.printStackTrace() + } +} + +private fun loadSettingsFromFile(context: Context): AppSettings { + val file = File(context.filesDir, "app_settings.json") + return if (file.exists()) { + try { + Gson().fromJson(file.readText(), AppSettings::class.java) + } catch (e: Exception) { + AppSettings() + } + } else { + AppSettings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/OverviewScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/OverviewScreen.kt new file mode 100644 index 0000000..2037b9c --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/OverviewScreen.kt @@ -0,0 +1,795 @@ +package com.samuel.inventorymanager.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Inventory +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.LocationCity +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Scale +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Straighten +import androidx.compose.material.icons.filled.TrendingUp +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// ====================================================================== +// MAIN OVERVIEW SCREEN - REDESIGNED +// ====================================================================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OverviewScreen( + items: List, + garages: List +) { + var searchQuery by remember { mutableStateOf("") } + var selectedLocation by remember { mutableStateOf("All Locations") } + var selectedSize by remember { mutableStateOf("All Sizes") } + var selectedCondition by remember { mutableStateOf("All Conditions") } + var showLocationMenu by remember { mutableStateOf(false) } + var showSizeMenu by remember { mutableStateOf(false) } + var showConditionMenu by remember { mutableStateOf(false) } + var selectedTab by remember { mutableStateOf(0) } + var expandedStats by remember { mutableStateOf(true) } + var expandedAnalytics by remember { mutableStateOf(true) } + + val filteredItems = remember(searchQuery, selectedLocation, selectedSize, selectedCondition, items) { + items.filter { item -> + val matchesSearch = searchQuery.isBlank() || + item.name.contains(searchQuery, ignoreCase = true) || + item.modelNumber?.contains(searchQuery, ignoreCase = true) == true + val matchesLocation = selectedLocation == "All Locations" || + (garages.find { it.id == item.garageId }?.name == selectedLocation) + val matchesSize = selectedSize == "All Sizes" || + item.sizeCategory.equals(selectedSize, ignoreCase = true) + val matchesCondition = selectedCondition == "All Conditions" || + item.condition.equals(selectedCondition, ignoreCase = true) + matchesSearch && matchesLocation && matchesSize && matchesCondition + } + } + + val totalItemsCount = filteredItems.size + val totalQuantity = filteredItems.sumOf { it.quantity } + val totalValue = filteredItems.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity } + val totalWeight = filteredItems.sumOf { (it.weight ?: 0.0) * it.quantity } + val locationsUsed = filteredItems.map { it.garageId }.distinct().count() + val avgItemValue = if (totalItemsCount > 0) totalValue / totalItemsCount else 0.0 + + val conditions = items.map { it.condition }.distinct().sorted() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Icon( + Icons.Default.List, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + "Inventory Overview", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.weight(1f)) + IconButton(onClick = { /* TODO: Export */ }) { + Icon(Icons.Default.FileDownload, "Export", tint = MaterialTheme.colorScheme.primary) + } + IconButton(onClick = { /* TODO: Refresh */ }) { + Icon(Icons.Default.Refresh, "Refresh", tint = MaterialTheme.colorScheme.primary) + } + } + } + + // Search Bar + item { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search by name or model number...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Default.Clear, "Clear") + } + } + }, + shape = RoundedCornerShape(16.dp), + singleLine = true + ) + } + + // Filter Row + item { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + FilterDropdown( + label = selectedLocation, + icon = Icons.Default.LocationOn, + expanded = showLocationMenu, + onExpandChange = { showLocationMenu = it }, + options = listOf("All Locations") + garages.map { it.name }, + onSelect = { selectedLocation = it; showLocationMenu = false }, + modifier = Modifier.weight(1f) + ) + + FilterDropdown( + label = selectedSize, + icon = Icons.Default.Straighten, + expanded = showSizeMenu, + onExpandChange = { showSizeMenu = it }, + options = listOf("All Sizes", "Small", "Medium", "Large"), + onSelect = { selectedSize = it; showSizeMenu = false }, + modifier = Modifier.weight(1f) + ) + + FilterDropdown( + label = selectedCondition, + icon = Icons.Default.CheckCircle, + expanded = showConditionMenu, + onExpandChange = { showConditionMenu = it }, + options = listOf("All Conditions") + conditions, + onSelect = { selectedCondition = it; showConditionMenu = false }, + modifier = Modifier.weight(1f) + ) + } + } + + // Statistics Section + item { + ExpandableSection( + title = "Key Statistics", + subtitle = "${filteredItems.size} items", + icon = Icons.Default.TrendingUp, + isExpanded = expandedStats, + onToggle = { expandedStats = !expandedStats } + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ModernStatCard( + title = "Unique Items", + value = totalItemsCount.toString(), + icon = Icons.Default.Inventory, + color = Color(0xFF6200EE), + modifier = Modifier.weight(1f) + ) + ModernStatCard( + title = "Total Quantity", + value = totalQuantity.toString(), + icon = Icons.Default.Numbers, + color = Color(0xFF03DAC5), + modifier = Modifier.weight(1f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ModernStatCard( + title = "Total Value", + value = "$${String.format("%.0f", totalValue)}", + icon = Icons.Default.AttachMoney, + color = Color(0xFF047857), + modifier = Modifier.weight(1f) + ) + ModernStatCard( + title = "Avg Value", + value = "$${String.format("%.0f", avgItemValue)}", + icon = Icons.Default.TrendingUp, + color = Color(0xFFB45309), + modifier = Modifier.weight(1f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ModernStatCard( + title = "Total Weight", + value = "${String.format("%.1f", totalWeight)} lbs", + icon = Icons.Default.Scale, + color = Color(0xFF0369A1), + modifier = Modifier.weight(1f) + ) + ModernStatCard( + title = "Locations Used", + value = "$locationsUsed/${garages.size}", + icon = Icons.Default.LocationCity, + color = Color(0xFFBE185D), + modifier = Modifier.weight(1f) + ) + } + } + } + } + + // Advanced Analytics Section + item { + ExpandableSection( + title = "Advanced Analytics", + subtitle = "Visual breakdown", + icon = Icons.Default.Category, + isExpanded = expandedAnalytics, + onToggle = { expandedAnalytics = !expandedAnalytics } + ) { + Column { + TabRow( + selectedTabIndex = selectedTab, + containerColor = Color.Transparent + ) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text("Category", fontSize = 13.sp) } + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text("Condition", fontSize = 13.sp) } + ) + Tab( + selected = selectedTab == 2, + onClick = { selectedTab = 2 }, + text = { Text("Location", fontSize = 13.sp) } + ) + Tab( + selected = selectedTab == 3, + onClick = { selectedTab = 3 }, + text = { Text("Size", fontSize = 13.sp) } + ) + } + + Spacer(Modifier.height(20.dp)) + + when (selectedTab) { + 0 -> AdvancedAnalyticsView( + groupedItems = filteredItems.groupBy { it.sizeCategory }, + label = "Category" + ) + 1 -> AdvancedAnalyticsView( + groupedItems = filteredItems.groupBy { it.condition }, + label = "Condition" + ) + 2 -> { + val locationData = garages.associate { garage -> + garage.name to filteredItems.filter { it.garageId == garage.id } + }.filter { it.value.isNotEmpty() } + AdvancedAnalyticsView(locationData, "Location") + } + 3 -> AdvancedAnalyticsView( + groupedItems = filteredItems.groupBy { it.sizeCategory }, + label = "Size" + ) + } + } + } + } + + // Item List + item { + Text( + "Item List (${filteredItems.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp) + ) + } + + items(filteredItems) { item -> + ModernItemCard(item = item, garages = garages) + } + } +} + +// ====================================================================== +// MODERN COMPONENTS +// ====================================================================== + +@Composable +fun FilterDropdown( + label: String, + icon: ImageVector, + expanded: Boolean, + onExpandChange: (Boolean) -> Unit, + options: List, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + OutlinedButton( + onClick = { onExpandChange(true) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Icon(icon, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + label, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 13.sp + ) + Icon(Icons.Default.ArrowDropDown, null, modifier = Modifier.size(20.dp)) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandChange(false) } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { onSelect(option) } + ) + } + } + } +} + +@Composable +fun ModernStatCard( + title: String, + value: String, + icon: ImageVector, + color: Color, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.height(100.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Icon( + icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(28.dp) + ) + Column { + Text( + value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + title, + style = MaterialTheme.typography.bodySmall, + color = color.copy(alpha = 0.8f) + ) + } + } + } +} + +@Composable +fun ExpandableSection( + title: String, + subtitle: String, + icon: ImageVector, + isExpanded: Boolean, + onToggle: () -> Unit, + content: @Composable () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle), + elevation = CardDefaults.cardElevation(4.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + icon, + null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp) + ) + Spacer(Modifier.width(12.dp)) + Column { + Text( + title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + "Toggle", + modifier = Modifier.size(28.dp) + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + Spacer(Modifier.height(20.dp)) + content() + } + } + } + } +} + +@Composable +fun AdvancedAnalyticsView( + groupedItems: Map>, + label: String +) { + val sortedData = groupedItems.map { (key, items) -> + val totalValue = items.sumOf { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity } + val totalQty = items.sumOf { it.quantity } + AnalyticsData(key, items.size, totalQty, totalValue) + }.sortedByDescending { it.totalValue } + + val maxValue = sortedData.maxOfOrNull { it.totalValue } ?: 1.0 + val total = sortedData.sumOf { it.itemCount } + + val colors = listOf( + Color(0xFF6200EE), + Color(0xFF03DAC5), + Color(0xFFFF6B6B), + Color(0xFF4ECDC4), + Color(0xFFFFBE0B), + Color(0xFFFFA500) + ) + + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + // Pie Chart with Legend + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + Canvas( + modifier = Modifier.size(180.dp) + ) { + val canvasSize = size.minDimension + val radius = canvasSize / 2 + val center = Offset(size.width / 2, size.height / 2) + + var startAngle = -90f + sortedData.forEachIndexed { index, data -> + val sweepAngle = (data.itemCount.toFloat() / total) * 360f + drawArc( + color = colors[index % colors.size], + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = true, + topLeft = Offset(center.x - radius, center.y - radius), + size = Size(radius * 2, radius * 2) + ) + startAngle += sweepAngle + } + + drawCircle( + color = Color.White, + radius = radius * 0.5f, + center = center + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + sortedData.take(6).forEachIndexed { index, data -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(12.dp) + .background(colors[index % colors.size], CircleShape) + ) + Spacer(Modifier.width(8.dp)) + Text( + "${data.name} (${data.itemCount})", + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + Divider() + + // Data Table + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + label, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(0.3f), + fontSize = 13.sp + ) + Text( + "Items", + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.weight(0.2f), + fontSize = 13.sp + ) + Text( + "Qty", + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.weight(0.2f), + fontSize = 13.sp + ) + Text( + "Value", + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + modifier = Modifier.weight(0.3f), + fontSize = 13.sp + ) + } + + sortedData.forEachIndexed { index, data -> + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(0.3f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(colors[index % colors.size], CircleShape) + ) + Spacer(Modifier.width(6.dp)) + Text( + data.name, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + "${data.itemCount}", + textAlign = TextAlign.Center, + modifier = Modifier.weight(0.2f), + fontSize = 13.sp + ) + Text( + "${data.totalQuantity}", + textAlign = TextAlign.Center, + modifier = Modifier.weight(0.2f), + fontSize = 13.sp + ) + Text( + "$${String.format("%.0f", data.totalValue)}", + textAlign = TextAlign.End, + modifier = Modifier.weight(0.3f), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction = (data.totalValue / maxValue).toFloat()) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(colors[index % colors.size]) + ) + } + } + } + } + } +} + +@Composable +fun ModernItemCard( + item: Item, + garages: List +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + item.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + getReadableLocation(item, garages), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(Modifier.width(16.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(horizontalAlignment = Alignment.End) { + Text( + "Qty: ${item.quantity}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + val avgPrice = ((item.minPrice ?: 0.0) + (item.maxPrice ?: 0.0)) / 2 + if (avgPrice > 0) { + Text( + "$${String.format("%.0f", avgPrice)}", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF047857), + fontWeight = FontWeight.Bold + ) + } + } + + ConditionIndicator(item.condition) + } + } + } +} + +@Composable +fun ConditionIndicator(condition: String) { + val color = when (condition.lowercase()) { + "new" -> Color(0xFF047857) + "like new" -> Color(0xFF1E40AF) + "good" -> Color(0xFFB45309) + "fair" -> Color(0xFFC2410C) + "poor" -> Color(0xFF9F1239) + else -> Color(0xFF4B5563) + } + + Box( + modifier = Modifier + .size(12.dp) + .background(color, CircleShape) + ) +} + +fun getReadableLocation(item: Item, garages: List): String { + val garage = garages.find { it.id == item.garageId } ?: return "Unknown" + val cabinet = garage.cabinets.find { it.id == item.cabinetId } ?: return garage.name + val shelf = cabinet.shelves.find { it.id == item.shelfId } ?: return "${garage.name} > ${cabinet.name}" + val box = shelf.boxes.find { it.id == item.boxId } + + val shelfAndBox = if (box != null) "${shelf.name} > ${box.name}" else shelf.name + + return if (garage.name.equals(cabinet.name, ignoreCase = true)) { + shelfAndBox + } else { + "${cabinet.name} > $shelfAndBox" + } +} + +data class AnalyticsData( + val name: String, + val itemCount: Int, + val totalQuantity: Int, + val totalValue: Double +) \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/SearchScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/SearchScreen.kt new file mode 100644 index 0000000..cae82d9 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/SearchScreen.kt @@ -0,0 +1,484 @@ +package com.samuel.inventorymanager.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + items: List, + garages: List, + onItemClick: ((Item) -> Unit)? = null +) { + var searchQuery by remember { mutableStateOf("") } + var selectedCategory by remember { mutableStateOf("All Categories") } + var selectedLocation by remember { mutableStateOf("All Locations") } + var isFiltersExpanded by remember { mutableStateOf(false) } + + // Create dropdown options + val categories = remember(items) { + listOf("All Categories") + items.map { it.sizeCategory }.distinct().sorted() + } + + val locations = remember(garages) { + listOf("All Locations") + garages.map { it.name }.sorted() + } + + // Filter items based on search criteria + val filteredItems = remember(searchQuery, selectedCategory, selectedLocation, items) { + items.filter { item -> + val matchesSearch = searchQuery.isBlank() || + item.name.contains(searchQuery, ignoreCase = true) || + item.modelNumber?.contains(searchQuery, ignoreCase = true) == true || + item.description?.contains(searchQuery, ignoreCase = true) == true + + val matchesCategory = selectedCategory == "All Categories" || + item.sizeCategory == selectedCategory + + val matchesLocation = selectedLocation == "All Locations" || + garages.find { it.id == item.garageId }?.name == selectedLocation + + matchesSearch && matchesCategory && matchesLocation + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + // Compact Search Header + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(0.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Main Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search by name, model, or description") }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = "Search") + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Filters Toggle Button + OutlinedButton( + onClick = { isFiltersExpanded = !isFiltersExpanded }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (isFiltersExpanded) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else + MaterialTheme.colorScheme.surface + ) + ) { + Icon( + Icons.Default.FilterList, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Advanced Filters", + modifier = Modifier.weight(1f) + ) + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (isFiltersExpanded) 180f else 0f) + ) + } + + // Expandable Filters Section + AnimatedVisibility( + visible = isFiltersExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(top = 16.dp) + ) { + // Category Dropdown + DropdownMenuField( + label = "Category", + selectedValue = selectedCategory, + options = categories, + onValueChange = { selectedCategory = it }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Location Dropdown + DropdownMenuField( + label = "Location", + selectedValue = selectedLocation, + options = locations, + onValueChange = { selectedLocation = it }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Clear Filters Button + OutlinedButton( + onClick = { + selectedCategory = "All Categories" + selectedLocation = "All Locations" + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + Icons.Default.Clear, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Clear Filters") + } + } + } + } + } + + // Results Count Bar + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${filteredItems.size} item${if (filteredItems.size != 1) "s" else ""} found", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (searchQuery.isNotEmpty() || selectedCategory != "All Categories" || selectedLocation != "All Locations") { + Text( + text = "Clear All", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.clickable { + searchQuery = "" + selectedCategory = "All Categories" + selectedLocation = "All Locations" + } + ) + } + } + + // Results List + if (filteredItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = if (searchQuery.isEmpty() && selectedCategory == "All Categories" && selectedLocation == "All Locations") { + "Enter search terms or use filters" + } else { + "No items found" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(filteredItems) { item -> + CleanItemCard( + item = item, + garages = garages, + onClick = { onItemClick?.invoke(item) } + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DropdownMenuField( + label: String, + selectedValue: String, + options: List, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier + ) { + OutlinedTextField( + value = selectedValue, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onValueChange(option) + expanded = false + } + ) + } + } + } +} + +@Composable +private fun CleanItemCard( + item: Item, + garages: List, + onClick: () -> Unit +) { + val locationPath = remember(item, garages) { + buildLocationPath(item, garages) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Left Content + Column( + modifier = Modifier.weight(1f) + ) { + // Item Name + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + + if (item.modelNumber != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Model: ${item.modelNumber}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Location + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = locationPath, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Additional Info Row + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (item.quantity > 0) { + InfoChip( + label = "Qty", + value = item.quantity.toString() + ) + } + if (item.sizeCategory.isNotEmpty()) { + InfoChip( + label = "Size", + value = item.sizeCategory + ) + } + if (item.condition.isNotEmpty()) { + InfoChip( + label = "Condition", + value = item.condition + ) + } + } + } + + // Right Arrow + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = "View Details", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(24.dp) + ) + } + } +} + +@Composable +private fun InfoChip( + label: String, + value: String +) { + Row( + modifier = Modifier + .background( + MaterialTheme.colorScheme.secondaryContainer, + RoundedCornerShape(6.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "$label: ", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + Text( + text = value, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } +} + +private fun buildLocationPath(item: Item, garages: List): String { + val garage = garages.find { it.id == item.garageId } + val cabinet = garage?.cabinets?.find { it.id == item.cabinetId } + val shelf = cabinet?.shelves?.find { it.id == item.shelfId } + val box = shelf?.boxes?.find { it.id == item.boxId } + + return buildString { + garage?.let { append(it.name) } + cabinet?.let { append(" → ${it.name}") } + shelf?.let { append(" → ${it.name}") } + box?.let { append(" → ${it.name}") } + }.ifEmpty { "Unknown Location" } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/SettingScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/SettingScreen.kt new file mode 100644 index 0000000..cc87f1f --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/SettingScreen.kt @@ -0,0 +1,1356 @@ +@file:Suppress("DEPRECATION") + +package com.samuel.inventorymanager.screens + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.DocumentScanner +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.common.api.ApiException +import com.samuel.inventorymanager.auth.GoogleAuthManager +import com.samuel.inventorymanager.data.AISettings +import com.samuel.inventorymanager.data.AppSettings +import com.samuel.inventorymanager.data.AppTheme +import com.samuel.inventorymanager.data.AutoFeatures +import com.samuel.inventorymanager.data.CustomTheme +import com.samuel.inventorymanager.data.FontSize +import com.samuel.inventorymanager.data.GoogleSettings +import com.samuel.inventorymanager.data.OCRSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + currentSettings: AppSettings, + onSettingsChange: (AppSettings) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val authManager = remember { GoogleAuthManager(context) } + + var settings by remember { mutableStateOf(currentSettings) } + var showColorPicker by remember { mutableStateOf(false) } + var showResetDialog by remember { mutableStateOf(false) } + var isProcessing by remember { mutableStateOf(false) } + var feedbackMessage by remember { mutableStateOf(null) } + + LaunchedEffect(currentSettings) { + settings = currentSettings + } + + LaunchedEffect(feedbackMessage) { + feedbackMessage?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + kotlinx.coroutines.delay(2000) + feedbackMessage = null + } + } + + val storagePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val granted = permissions.values.all { it } + feedbackMessage = if (granted) "✅ Storage permission granted" else "❌ Storage permission denied" + } + + val manageStorageLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + val granted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else true + feedbackMessage = if (granted) "✅ Storage access granted" else "❌ Storage access denied" + } + + val requestStoragePermission = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!Environment.isExternalStorageManager()) { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = Uri.parse("package:${context.packageName}") + } + manageStorageLauncher.launch(intent) + } + } else { + storagePermissionLauncher.launch( + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + ) + } + } + + // Firebase Google Sign-In Launcher + val googleSignInLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(ApiException::class.java) + val idToken = account?.idToken + + if (idToken != null) { + authManager.handleSignInResult( + idToken, + onSuccess = { message -> + settings = settings.copy( + googleSettings = settings.googleSettings.copy( + signedIn = true, + userEmail = authManager.getCurrentUserEmail() + ) + ) + onSettingsChange(settings) + feedbackMessage = message + }, + onError = { error -> + feedbackMessage = error + } + ) + } + } catch (e: ApiException) { + feedbackMessage = "❌ Sign in failed: ${e.message}" + } + } else { + feedbackMessage = "❌ Sign in cancelled" + } + } + + val onSignInClick: () -> Unit = { + val signInIntent = authManager.getSignInIntent() + googleSignInLauncher.launch(signInIntent) + } + + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + scope.launch { + isProcessing = true + val result = importSettings(context, it) + isProcessing = false + result?.let { importedSettings -> + settings = importedSettings + onSettingsChange(importedSettings) + feedbackMessage = "✅ Settings imported successfully" + } ?: run { + feedbackMessage = "❌ Failed to import settings" + } + } + } + } + + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri: Uri? -> + uri?.let { + scope.launch { + isProcessing = true + val success = exportSettings(context, settings, it) + isProcessing = false + feedbackMessage = if (success) "✅ Settings exported successfully" + else "❌ Failed to export settings" + } + } + } + + var themeExpanded by remember { mutableStateOf(true) } + var ocrExpanded by remember { mutableStateOf(false) } + var aiExpanded by remember { mutableStateOf(false) } + var googleExpanded by remember { mutableStateOf(false) } + var dataExpanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + feedbackMessage?.let { message -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (message.startsWith("✅")) + MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + message, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + item { + ExpandableCard( + title = "Theme Settings", + icon = Icons.Default.Palette, + expanded = themeExpanded, + onToggle = { themeExpanded = !themeExpanded } + ) { + ThemeSettingsContent( + settings = settings, + onThemeChange = { newTheme -> + settings = settings.copy(theme = newTheme) + onSettingsChange(settings) + }, + onFontSizeChange = { newFontSize -> + settings = settings.copy(fontSize = newFontSize) + onSettingsChange(settings) + }, + onCustomThemeClick = { showColorPicker = true } + ) + } + } + + item { + ExpandableCard( + title = "OCR Settings & Fallback Priority", + icon = Icons.Default.DocumentScanner, + expanded = ocrExpanded, + onToggle = { ocrExpanded = !ocrExpanded } + ) { + OCRSettingsContent( + ocrSettings = settings.ocrSettings, + onUpdate = { newOcrSettings -> + settings = settings.copy(ocrSettings = newOcrSettings) + onSettingsChange(settings) + } + ) + } + } + + item { + ExpandableCard( + title = "AI Settings & Fallback Priority", + icon = Icons.Default.Psychology, + expanded = aiExpanded, + onToggle = { aiExpanded = !aiExpanded } + ) { + AISettingsContent( + aiSettings = settings.aiSettings, + onUpdate = { newAiSettings -> + settings = settings.copy(aiSettings = newAiSettings) + onSettingsChange(settings) + } + ) + } + } + + item { + ExpandableCard( + title = "Google Settings", + icon = Icons.Default.Cloud, + expanded = googleExpanded, + onToggle = { googleExpanded = !googleExpanded } + ) { + GoogleSettingsContent( + authManager = authManager, + googleSettings = settings.googleSettings, + onSignInClick = onSignInClick, + onUpdate = { newGoogleSettings -> + settings = settings.copy(googleSettings = newGoogleSettings) + onSettingsChange(settings) + }, + onBackupNow = { + scope.launch { + isProcessing = true + authManager.uploadToDrive( + "inventory_backup_${System.currentTimeMillis()}.json", + onSuccess = { message -> + isProcessing = false + val updated = settings.copy( + googleSettings = settings.googleSettings.copy( + lastBackupTime = System.currentTimeMillis() + ) + ) + settings = updated + onSettingsChange(updated) + feedbackMessage = message + } + ) + } + } + ) + } + } + + item { + ExpandableCard( + title = "Data Management & Android Features", + icon = Icons.Default.Storage, + expanded = dataExpanded, + onToggle = { dataExpanded = !dataExpanded } + ) { + DataManagementContent( + autoFeatures = settings.autoFeatures, + onRequestPermissions = requestStoragePermission, + onUpdate = { newAutoFeatures -> + settings = settings.copy(autoFeatures = newAutoFeatures) + onSettingsChange(settings) + }, + onExport = { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) + .format(Date()) + exportLauncher.launch("inventory_settings_$timestamp.json") + }, + onImport = { + importLauncher.launch("application/json") + }, + onLocalSaveNow = { + scope.launch { + isProcessing = true + val success = performLocalSave(context, settings) + isProcessing = false + if (success) { + val updated = settings.copy( + autoFeatures = settings.autoFeatures.copy( + lastLocalSaveTime = System.currentTimeMillis() + ) + ) + settings = updated + onSettingsChange(updated) + feedbackMessage = "✅ Local save completed" + } else { + feedbackMessage = "❌ Local save failed" + } + } + } + ) + } + } + + item { + Button( + onClick = { showResetDialog = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(Icons.Default.RestartAlt, null) + Spacer(Modifier.width(8.dp)) + Text("Reset All Settings") + } + } + } + + if (isProcessing) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + } + + if (showColorPicker) { + CustomThemeDialog( + currentTheme = settings.customTheme ?: CustomTheme(), + onDismiss = { showColorPicker = false }, + onConfirm = { customTheme -> + settings = settings.copy(theme = AppTheme.CUSTOM, customTheme = customTheme) + onSettingsChange(settings) + showColorPicker = false + } + ) + } + + if (showResetDialog) { + AlertDialog( + onDismissRequest = { showResetDialog = false }, + title = { Text("Reset Settings") }, + text = { Text("Are you sure you want to reset all settings to default?") }, + confirmButton = { + Button( + onClick = { + settings = AppSettings() + onSettingsChange(settings) + showResetDialog = false + feedbackMessage = "✅ Settings reset to default" + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { Text("Reset") } + }, + dismissButton = { + TextButton({ showResetDialog = false }) { Text("Cancel") } + } + ) + } +} + +// ======================================================================================== +// HELPER FUNCTIONS +// ======================================================================================== + +suspend fun exportSettings(context: Context, settings: AppSettings, uri: Uri): Boolean { + return withContext(Dispatchers.IO) { + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(settings.toJson().toByteArray()) + } + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} + +suspend fun importSettings(context: Context, uri: Uri): AppSettings? { + return withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + val json = inputStream.bufferedReader().readText() + AppSettings.fromJson(json) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +suspend fun performLocalSave(context: Context, settings: AppSettings): Boolean { + return withContext(Dispatchers.IO) { + try { + val file = java.io.File(context.filesDir, "app_settings.json") + file.writeText(settings.toJson()) + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} + +// ======================================================================================== +// COMPOSABLE COMPONENTS +// ======================================================================================== + +@Composable +fun ExpandableCard( + title: String, + icon: ImageVector, + expanded: Boolean, + onToggle: () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Icon( + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + null + ) + } + AnimatedVisibility(visible = expanded) { + Column( + Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content + ) + } + } + } +} + +@Composable +fun ThemeSettingsContent( + settings: AppSettings, + onThemeChange: (AppTheme) -> Unit, + onFontSizeChange: (FontSize) -> Unit, + onCustomThemeClick: () -> Unit +) { + val themeOptions = listOf( + "☀️ Light" to Color(0xFFFFFBFE), + "🌙 Dark" to Color(0xFF1A1A1A), + "🧛 Dracula" to Color(0xFFBD93F9), + "🧟 Vampire" to Color(0xFFFF1493), + "🌊 Ocean" to Color(0xFF00B4D8), + "🌲 Forest" to Color(0xFF2D6A4F), + "🌅 Sunset" to Color(0xFFFF6B35), + "⚙️ Cyberpunk" to Color(0xFFFF006E), + "⚡ Neon" to Color(0xFF39FF14) + ) + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Select Theme", style = MaterialTheme.typography.labelLarge) + + // --- FIX START --- + // Replace LazyVerticalGrid with a standard Column and Rows to build the grid + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Group the theme options into rows of 3 + themeOptions.chunked(3).forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Create a ThemeOptionCard for each item in the row, giving it equal weight + rowItems.forEach { (name, color) -> + Box(modifier = Modifier.weight(1f)) { + ThemeOptionCard(name, color, settings.theme == AppTheme.CUSTOM) { + onThemeChange(AppTheme.CUSTOM) + onCustomThemeClick() + } + } + } + // Add invisible spacers to fill the row if it has fewer than 3 items. + // This ensures items in the last row align correctly with the rows above. + if (rowItems.size < 3) { + repeat(3 - rowItems.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + // --- FIX END --- + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + Text("Font & Icon Size", style = MaterialTheme.typography.labelLarge) + Text( + "Select default text size", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FontSize.entries.forEach { size -> + FontSizeButton( + size.name.lowercase().replaceFirstChar { it.uppercase() }, + settings.fontSize == size + ) { onFontSizeChange(size) } + } + } + } +} + +@Composable +fun ThemeOptionCard( + themeName: String, + themeColor: Color, + isSelected: Boolean, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = themeColor.copy(alpha = 0.2f) + ), + border = if (isSelected) BorderStroke(3.dp, themeColor) else null, + elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf( + themeColor.copy(alpha = 0.4f), + themeColor.copy(alpha = 0.1f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + themeName, + style = MaterialTheme.typography.labelMedium, + color = Color.Black, + fontWeight = FontWeight.Bold + ) + if (isSelected) { + Icon( + Icons.Default.Check, + null, + tint = themeColor, + modifier = Modifier.size(16.dp) + ) + } + } + } + } +} + +@Composable +fun RowScope.FontSizeButton(text: String, selected: Boolean, onClick: () -> Unit) { + Button( + onClick, + Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = if (selected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Text(text, fontSize = 11.sp, maxLines = 1) + } +} + +@Composable +fun OCRSettingsContent(ocrSettings: OCRSettings, onUpdate: (OCRSettings) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "OCR Priority (Use Fallback)", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + "Higher priority providers are tried first", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + ocrSettings.providerPriority.forEachIndexed { index, provider -> + ProviderPriorityItem( + name = provider.name.replace("_", " ").lowercase() + .split(" ").joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }, + priority = index + 1, + onMoveUp = if (index > 0) { + { + val newList = ocrSettings.providerPriority.toMutableList() + val temp = newList[index] + newList[index] = newList[index - 1] + newList[index - 1] = temp + onUpdate(ocrSettings.copy(providerPriority = newList)) + } + } else null, + onMoveDown = if (index < ocrSettings.providerPriority.size - 1) { + { + val newList = ocrSettings.providerPriority.toMutableList() + val temp = newList[index] + newList[index] = newList[index + 1] + newList[index + 1] = temp + onUpdate(ocrSettings.copy(providerPriority = newList)) + } + } else null + ) + } + + Button( + onClick = { onUpdate(ocrSettings.copy(providerPriority = OCRSettings().providerPriority)) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors() + ) { + Icon(Icons.Default.RestartAlt, null) + Spacer(Modifier.width(8.dp)) + Text("Reset to Default Priority") + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + APIKeyField("Roboflow API Key", ocrSettings.roboflowApiKey) { + onUpdate(ocrSettings.copy(roboflowApiKey = it)) + } + APIKeyField("OCR Space API Key", ocrSettings.ocrSpaceApiKey) { + onUpdate(ocrSettings.copy(ocrSpaceApiKey = it)) + } + APIKeyField("Google Vision API Key", ocrSettings.googleVisionApiKey) { + onUpdate(ocrSettings.copy(googleVisionApiKey = it)) + } + } +} + +@Composable +fun AISettingsContent(aiSettings: AISettings, onUpdate: (AISettings) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "AI Priority (Use Fallback)", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + "Higher priority providers are tried first", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + aiSettings.providerPriority.forEachIndexed { index, provider -> + ProviderPriorityItem( + name = provider.name.replace("_", " ").lowercase() + .split(" ").joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }, + priority = index + 1, + onMoveUp = if (index > 0) { + { + val newList = aiSettings.providerPriority.toMutableList() + val temp = newList[index] + newList[index] = newList[index - 1] + newList[index - 1] = temp + onUpdate(aiSettings.copy(providerPriority = newList)) + } + } else null, + onMoveDown = if (index < aiSettings.providerPriority.size - 1) { + { + val newList = aiSettings.providerPriority.toMutableList() + val temp = newList[index] + newList[index] = newList[index + 1] + newList[index + 1] = temp + onUpdate(aiSettings.copy(providerPriority = newList)) + } + } else null + ) + } + + Icon(Icons.Default.RestartAlt, null) + Spacer(Modifier.width(8.dp)) + Text("Reset to Default Priority") + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + APIKeyField("Google Gemini API Key", aiSettings.googleGeminiApiKey) { + onUpdate(aiSettings.copy(googleGeminiApiKey = it)) + } + APIKeyField("ChatGPT API Key", aiSettings.openAIApiKey) { + onUpdate(aiSettings.copy(openAIApiKey = it)) + } +} + + +@Composable +fun GoogleSettingsContent( + authManager: GoogleAuthManager, + googleSettings: GoogleSettings, + onSignInClick: () -> Unit, + onUpdate: (GoogleSettings) -> Unit, + onBackupNow: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (authManager.isSignedIn()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.CheckCircle, + "Signed In", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + "✅ Signed In", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + authManager.getCurrentUserEmail(), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + SwitchRow( + "🔄 Auto Backup to Drive", + googleSettings.autoBackupToDrive + ) { + onUpdate(googleSettings.copy(autoBackupToDrive = it)) + } + + if (googleSettings.lastBackupTime > 0) { + Text( + "Last backup: ${SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(googleSettings.lastBackupTime))}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Button( + onClick = onBackupNow, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Backup, null) + Spacer(Modifier.width(8.dp)) + Text("Backup Now") + } + + Button( + onClick = { + authManager.signOut() + onUpdate(GoogleSettings()) + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(Icons.Default.Logout, null) + Spacer(Modifier.width(8.dp)) + Text("Sign Out") + } + } else { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "ℹ️ Why Sign In?", + fontWeight = FontWeight.Bold + ) + Text( + "• Backup your inventory to Google Drive\n" + + "• Access data across devices\n" + + "• Never lose your data", + style = MaterialTheme.typography.bodySmall + ) + } + } + + Button( + onClick = onSignInClick, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF4285F4) + ) + ) { + Icon(Icons.Default.Login, null) + Spacer(Modifier.width(8.dp)) + Text("Sign in with Google") + } + } + } +} + +@Composable +fun DataManagementContent( + autoFeatures: AutoFeatures, + onRequestPermissions: () -> Unit, + onUpdate: (AutoFeatures) -> Unit, + onExport: () -> Unit, + onImport: () -> Unit, + onLocalSaveNow: () -> Unit +) { + val context = LocalContext.current + val hasStoragePermission = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (!hasStoragePermission) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Warning, + "Warning", + tint = MaterialTheme.colorScheme.error + ) + Spacer(Modifier.width(8.dp)) + Text( + "Storage Permission Required", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + Text( + "To save data locally, grant storage access", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Button( + onClick = onRequestPermissions, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(Icons.Default.Folder, null) + Spacer(Modifier.width(8.dp)) + Text("Grant Storage Permission") + } + } + } + } else { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.CheckCircle, + "Granted", + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(12.dp)) + Text( + "✅ Storage Permission Granted", + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + HorizontalDivider() + + SwitchRow("📦 Auto Local Save", autoFeatures.autoLocalSave) { + onUpdate(autoFeatures.copy(autoLocalSave = it)) + } + SwitchRow("☁️ Auto Google Backup", autoFeatures.autoGoogleBackup) { + onUpdate(autoFeatures.copy(autoGoogleBackup = it)) + } + + if (autoFeatures.lastLocalSaveTime > 0) { + Text( + "Last local save: ${SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(autoFeatures.lastLocalSaveTime))}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Button( + onClick = onLocalSaveNow, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Save, null) + Spacer(Modifier.width(8.dp)) + Text("Save Locally Now") + } + + HorizontalDivider() + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onExport, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Upload, null) + Spacer(Modifier.width(4.dp)) + Text("Export") + } + OutlinedButton( + onClick = onImport, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Download, null) + Spacer(Modifier.width(4.dp)) + Text("Import") + } + } + } +} + +@Composable +fun APIKeyField(label: String, value: String, onValueChange: (String) -> Unit) { + var showKey by remember { mutableStateOf(false) } + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (showKey) + VisualTransformation.None + else + PasswordVisualTransformation(), + trailingIcon = { + IconButton({ showKey = !showKey }) { + Icon( + if (showKey) Icons.Default.Visibility else Icons.Default.VisibilityOff, + if (showKey) "Hide" else "Show" + ) + } + }, + singleLine = true + ) +} + +@Composable +fun SwitchRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, Modifier.weight(1f)) + Switch(checked, onCheckedChange) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomThemeDialog( + currentTheme: CustomTheme, + onDismiss: () -> Unit, + onConfirm: (CustomTheme) -> Unit +) { + var theme by remember { mutableStateOf(currentTheme) } + var fontSizeScale by remember { mutableFloatStateOf(currentTheme.fontSizeScale) } + + val colorOptions = listOf( + "Red" to Color(0xFFE53E3E), + "Orange" to Color(0xFFDD6B20), + "Yellow" to Color(0xFFD69E2E), + "Green" to Color(0xFF38A169), + "Teal" to Color(0xFF319795), + "Blue" to Color(0xFF3182CE), + "Purple" to Color(0xFF805AD5), + "Pink" to Color(0xFFD53F8C) + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("🎨 Custom Theme") }, + text = { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Text("Select Primary Color", fontWeight = FontWeight.Bold) + } + item { + colorOptions.chunked(4).forEach { rowColors -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowColors.forEach { (name, color) -> + Box( + Modifier + .weight(1f) + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(color) + .clickable { + theme = theme.copy( + primaryColor = color.toArgb().toLong() + ) + }, + contentAlignment = Alignment.Center + ) { + if (Color(theme.primaryColor.toULong()) == color) { + Icon( + Icons.Default.Check, + null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + Spacer(Modifier.height(8.dp)) + } + } + + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + + item { + Text("Select Background", fontWeight = FontWeight.Bold) + } + item { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + listOf( + "Light" to Color(0xFFFFFFFF), + "Dark" to Color(0xFF1A202C) + ).forEach { (name, color) -> + Box( + Modifier + .weight(1f) + .height(60.dp) + .clip(RoundedCornerShape(12.dp)) + .background(color) + .clickable { + theme = theme.copy( + backgroundColor = color.toArgb().toLong() + ) + }, + contentAlignment = Alignment.Center + ) { + Text( + name, + color = if (color == Color.White) Color.Black else Color.White, + fontWeight = if (Color(theme.backgroundColor.toULong()) == color) + FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + + item { + Text("Font & Icon Size Scale", fontWeight = FontWeight.Bold) + } + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Slider( + value = fontSizeScale, + onValueChange = { fontSizeScale = it }, + valueRange = 0.8f..1.5f, + steps = 13, + modifier = Modifier.fillMaxWidth() + ) + Text( + "Scale: %.2f (0.8x to 1.5x)".format(fontSizeScale), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + }, + confirmButton = { + Button({ + onConfirm(theme.copy(fontSizeScale = fontSizeScale)) + }) { Text("Apply Theme") } + }, + dismissButton = { + TextButton(onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +fun ProviderPriorityItem( + name: String, + priority: Int, + onMoveUp: (() -> Unit)?, + onMoveDown: (() -> Unit)? +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Text( + priority.toString(), + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + } + Spacer(Modifier.width(12.dp)) + Text(name, fontWeight = FontWeight.Medium) + } + Row { + IconButton( + onClick = { onMoveUp?.invoke() }, + enabled = onMoveUp != null + ) { + Icon( + Icons.Default.ArrowUpward, + "Move Up", + tint = if (onMoveUp != null) + MaterialTheme.colorScheme.primary + else + Color.Gray + ) + } + IconButton( + onClick = { onMoveDown?.invoke() }, + enabled = onMoveDown != null + ) { + Icon( + Icons.Default.ArrowDownward, + "Move Down", + tint = if (onMoveDown != null) + MaterialTheme.colorScheme.primary + else + Color.Gray + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/ui/theme/Color.kt b/app/src/main/java/com/samuel/inventorymanager/ui/theme/Color.kt new file mode 100644 index 0000000..e6078fe --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.samuel.inventorymanager.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/ui/theme/Theme.kt b/app/src/main/java/com/samuel/inventorymanager/ui/theme/Theme.kt new file mode 100644 index 0000000..fa781d8 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/ui/theme/Theme.kt @@ -0,0 +1,315 @@ +package com.samuel.inventorymanager.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// ======================================================================================== +// COLOR DEFINITIONS +// ======================================================================================== + +private val PurpleLight40 = Color(0xFF6650a4) +private val PurpleGreyLight40 = Color(0xFF625b71) +private val PinkLight40 = Color(0xFF7D5260) + +private val PurpleDark80 = Color(0xFFEADDF5) +private val PurpleGreyDark80 = Color(0xFFCCC7DB) +private val PinkDark80 = Color(0xFFEFB8C8) + +// Additional Theme Colors +private val LightBackground = Color(0xFFFFFBFE) +private val DarkBackground = Color(0xFF1A1A1A) + +private val DraculaPrimary = Color(0xFFBD93F9) +private val VampirePrimary = Color(0xFFFF1493) +private val OceanPrimary = Color(0xFF00B4D8) +private val ForestPrimary = Color(0xFF2D6A4F) +private val SunsetPrimary = Color(0xFFFF6B35) +private val CyberpunkPrimary = Color(0xFFFF006E) +private val NeonPrimary = Color(0xFF39FF14) + +// ======================================================================================== +// COLOR SCHEMES +// ======================================================================================== + +private val LightColorScheme = lightColorScheme( + primary = PurpleLight40, + secondary = PurpleGreyLight40, + tertiary = PinkLight40, + background = LightBackground, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F) +) + +private val DarkColorScheme = darkColorScheme( + primary = PurpleDark80, + secondary = PurpleGreyDark80, + tertiary = PinkDark80, + background = DarkBackground, + surface = Color(0xFF2A2A2A), + onPrimary = Color.Black, + onSecondary = Color.Black, + onTertiary = Color.Black, + onBackground = Color.White, + onSurface = Color.White +) + +private val DraculaLightScheme = lightColorScheme( + primary = DraculaPrimary, + secondary = Color(0xFF8BE9FD), + tertiary = Color(0xFFFF79C6), + background = Color(0xFFF8F8F2), + surface = Color.White +) + +private val DraculaDarkScheme = darkColorScheme( + primary = DraculaPrimary, + secondary = Color(0xFF8BE9FD), + tertiary = Color(0xFFFF79C6), + background = Color(0xFF282A36), + surface = Color(0xFF21222C) +) + +private val VampireLightScheme = lightColorScheme( + primary = VampirePrimary, + secondary = Color(0xFFE91E63), + tertiary = Color(0xFFC2185B), + background = Color(0xFFFFF1F5), + surface = Color.White +) + +private val VampireDarkScheme = darkColorScheme( + primary = VampirePrimary, + secondary = Color(0xFFE91E63), + tertiary = Color(0xFFC2185B), + background = Color(0xFF1A0015), + surface = Color(0xFF2A0A1F) +) + +private val OceanLightScheme = lightColorScheme( + primary = OceanPrimary, + secondary = Color(0xFF0096C7), + tertiary = Color(0xFF00B4D8), + background = Color(0xFFF0F8FF), + surface = Color.White +) + +private val OceanDarkScheme = darkColorScheme( + primary = OceanPrimary, + secondary = Color(0xFF0096C7), + tertiary = Color(0xFF00B4D8), + background = Color(0xFF001F3F), + surface = Color(0xFF003D5C) +) + +private val ForestLightScheme = lightColorScheme( + primary = ForestPrimary, + secondary = Color(0xFF40916C), + tertiary = Color(0xFF52B788), + background = Color(0xFFF1FAEE), + surface = Color.White +) + +private val ForestDarkScheme = darkColorScheme( + primary = ForestPrimary, + secondary = Color(0xFF40916C), + tertiary = Color(0xFF52B788), + background = Color(0xFF1B4332), + surface = Color(0xFF2D6A4F) +) + +private val SunsetLightScheme = lightColorScheme( + primary = SunsetPrimary, + secondary = Color(0xFFFFA500), + tertiary = Color(0xFFFFB703), + background = Color(0xFFFFF8F3), + surface = Color.White +) + +private val SunsetDarkScheme = darkColorScheme( + primary = SunsetPrimary, + secondary = Color(0xFFFFA500), + tertiary = Color(0xFFFFB703), + background = Color(0xFF3D2817), + surface = Color(0xFF5C3D2E) +) + +private val CyberpunkLightScheme = lightColorScheme( + primary = CyberpunkPrimary, + secondary = Color(0xFF00F5FF), + tertiary = Color(0xFFFFBE0B), + background = Color(0xFFF8F9FF), + surface = Color.White +) + +private val CyberpunkDarkScheme = darkColorScheme( + primary = CyberpunkPrimary, + secondary = Color(0xFF00F5FF), + tertiary = Color(0xFFFFBE0B), + background = Color(0xFF0A0E27), + surface = Color(0xFF1A1F3A) +) + +private val NeonLightScheme = lightColorScheme( + primary = NeonPrimary, + secondary = Color(0xFFFF006E), + tertiary = Color(0xFF00F5FF), + background = Color(0xFFFFFFF0), + surface = Color.White +) + +private val NeonDarkScheme = darkColorScheme( + primary = NeonPrimary, + secondary = Color(0xFFFF006E), + tertiary = Color(0xFF00F5FF), + background = Color(0xFF0D0221), + surface = Color(0xFF1A0033) +) + +// ======================================================================================== +// CUSTOM TYPOGRAPHY WITH FONT SCALING +// ======================================================================================== + +@Composable +fun getScaledTypography(scale: Float): Typography { + return Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = (57 * scale).sp + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = (45 * scale).sp + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = (36 * scale).sp + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = (32 * scale).sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = (28 * scale).sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = (24 * scale).sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = (22 * scale).sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = (16 * scale).sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = (14 * scale).sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = (16 * scale).sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = (14 * scale).sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = (12 * scale).sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = (14 * scale).sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = (12 * scale).sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = (11 * scale).sp + ) + ) +} + +// ======================================================================================== +// THEME ENUM +// ======================================================================================== + +enum class AppThemeType { + LIGHT, DARK, DRACULA, VAMPIRE, OCEAN, FOREST, SUNSET, CYBERPUNK, NEON +} + +// ======================================================================================== +// MAIN THEME COMPOSABLE +// ======================================================================================== + +@Composable +fun InventoryManagerTheme( + themeType: AppThemeType = AppThemeType.LIGHT, + darkTheme: Boolean = isSystemInDarkTheme(), + fontScale: Float = 1.0f, + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + themeType == AppThemeType.LIGHT -> LightColorScheme + themeType == AppThemeType.DARK -> DarkColorScheme + themeType == AppThemeType.DRACULA -> if (darkTheme) DraculaDarkScheme else DraculaLightScheme + themeType == AppThemeType.VAMPIRE -> if (darkTheme) VampireDarkScheme else VampireLightScheme + themeType == AppThemeType.OCEAN -> if (darkTheme) OceanDarkScheme else OceanLightScheme + themeType == AppThemeType.FOREST -> if (darkTheme) ForestDarkScheme else ForestLightScheme + themeType == AppThemeType.SUNSET -> if (darkTheme) SunsetDarkScheme else SunsetLightScheme + themeType == AppThemeType.CYBERPUNK -> if (darkTheme) CyberpunkDarkScheme else CyberpunkLightScheme + themeType == AppThemeType.NEON -> if (darkTheme) NeonDarkScheme else NeonLightScheme + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val scaledTypography = getScaledTypography(fontScale) + + MaterialTheme( + colorScheme = colorScheme, + typography = scaledTypography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/ui/theme/Type.kt b/app/src/main/java/com/samuel/inventorymanager/ui/theme/Type.kt new file mode 100644 index 0000000..b906bb8 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.samuel.inventorymanager.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..cfae3cc --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + InventoryManager + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d67f102 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +