diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44c7368..a7b19ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,31 +1,34 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) + alias(libs.plugins.compose.compiler) 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 + compileSdk = 34 defaultConfig { applicationId = "com.samuel.inventorymanager" minSdk = 24 - targetSdk = 36 + targetSdk = 34 versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + packaging { resources { - excludes += "META-INF/DEPENDENCIES" + excludes += "/META-INF/INDEX.LIST" + excludes += "/META-INF/*.md" + excludes += "/META-INF/DEPENDENCIES" + excludes += "/META-INF/LICENSE" + excludes += "/META-INF/LICENSE.txt" + excludes += "/META-INF/NOTICE" + excludes += "/META-INF/NOTICE.txt" } } - // == buildTypes { release { isMinifyEnabled = false @@ -47,55 +50,77 @@ android { } } -// 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") + // --- Platforms (BOMs) --- + implementation(platform("androidx.compose:compose-bom:2024.05.00")) + implementation(platform("com.google.firebase:firebase-bom:33.1.0")) -// Use one, consistent version + // --- AndroidX & Jetpack Compose --- + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") + implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3") + implementation("androidx.navigation:navigation-compose:2.7.7") - // For Google One Tap Sign-In - implementation("androidx.credentials:credentials:1.2.0") - implementation("androidx.credentials:credentials-play-services-auth:1.2.0") + // --- Coroutines --- + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + // --- Accompanist --- + implementation("com.google.accompanist:accompanist-pager:0.34.0") + implementation("com.google.accompanist:accompanist-pager-indicators:0.34.0") + + // --- Firebase, Auth, & Credentials --- + implementation("com.google.firebase:firebase-analytics") + implementation("com.google.firebase:firebase-auth") + implementation("com.google.firebase:firebase-database") + implementation("com.google.android.gms:play-services-auth:21.2.0") + implementation("androidx.credentials:credentials:1.3.0-alpha01") + implementation("androidx.credentials:credentials-play-services-auth:1.3.0-alpha01") implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0") + implementation("com.google.http-client:google-http-client-android:1.43.3") + implementation("com.google.zxing:core:3.5.3") - // --- 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") + // --- **CORRECTED** GOOGLE DRIVE API DEPENDENCIES --- + implementation("com.google.http-client:google-http-client-gson:1.44.1") { + exclude(group = "org.apache.httpcomponents") + } + implementation("com.google.api-client:google-api-client:2.4.0") { + exclude(group = "org.apache.httpcomponents") + } + implementation("com.google.api-client:google-api-client-android:2.4.0") { + exclude(group = "org.apache.httpcomponents") + } + // THIS VERSION EXISTS. MY OLD ONE DID NOT. + implementation("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0") { + exclude(group = "org.apache.httpcomponents") + } - // --- 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") + // --- Image & ML Kit (WITH CORRECT VERSIONS) --- + implementation("com.vanniktech:android-image-cropper:4.5.0") + implementation("io.coil-kt:coil-compose:2.6.0") + implementation("com.google.android.gms:play-services-mlkit-text-recognition:19.0.0") + implementation("com.google.mlkit:text-recognition:16.0.0") + // THIS VERSION EXISTS. MY OLD ONE DID NOT. + implementation("com.google.mlkit:image-labeling:17.0.8") + implementation("com.google.mlkit:object-detection:17.0.1") + + // --- JSON Parsing (WITH CORRECT VERSION) --- + implementation("com.google.code.gson:gson:2.10.1") // --- Testing --- testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(platform("androidx.compose:compose-bom:2024.05.00")) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + + // --- Debug --- debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2b23537..63ad4ed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,23 +8,27 @@ android:required="false" /> + + + + + tools:ignore="AllFilesAccessPolicy,ScopedStorage" /> + + - + Unit, + onCancel: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Edit Image") }, + navigationIcon = { + IconButton(onClick = onCancel) { + Icon(Icons.Default.Close, null) + } + }, + actions = { + TextButton(onClick = onSave) { + Text("Save") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = rememberAsyncImagePainter(uri), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Fit + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AssistChip(onClick = { }, label = { Text("Crop" ) }, leadingIcon = { Icon(Icons.Default.Crop, null) }) + AssistChip(onClick = { }, label = { Text("Rotate") }, leadingIcon = { Icon(Icons.Default.RotateRight, null) }) + AssistChip(onClick = { }, label = { Text("Brightness") }, leadingIcon = { Icon(Icons.Default.Brightness4, null) }) + } + } + } +} \ 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 index 414c73e..0a34fe2 100644 --- a/app/src/main/java/com/samuel/inventorymanager/MainActivity.kt +++ b/app/src/main/java/com/samuel/inventorymanager/MainActivity.kt @@ -1,13 +1,30 @@ -package com.samuel.inventorymanager // Ensure this matches your package name +@file:Suppress("DEPRECATION") + +package com.samuel.inventorymanager import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +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.Modifier +import androidx.compose.ui.platform.LocalContext +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.tasks.Task import com.samuel.inventorymanager.screens.MainAppScreen +import com.samuel.inventorymanager.screens.OnboardingScreen import com.samuel.inventorymanager.ui.theme.InventoryManagerTheme class MainActivity : ComponentActivity() { @@ -15,14 +32,56 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { InventoryManagerTheme { - // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - MainAppScreen() + AppEntry() } } } } +} + +@Composable +fun AppEntry() { + val context = LocalContext.current + var googleAccount by remember { + mutableStateOf(GoogleSignIn.getLastSignedInAccount(context)) + } + + val gso = remember { + GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .build() + } + + val googleSignInClient = remember { + GoogleSignIn.getClient(context, gso) + } + + val signInLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val task: Task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(ApiException::class.java)!! + Log.d("SIGN_IN_SUCCESS", "Signed in as: ${account.email}") + googleAccount = account + } catch (e: ApiException) { + Log.w("SIGN_IN_ERROR", "signInResult:failed code=" + e.statusCode) + } + } + + if (googleAccount == null) { + OnboardingScreen( + onGetStarted = {}, + onSignInWithGoogle = { + val signInIntent = googleSignInClient.signInIntent + signInLauncher.launch(signInIntent) + } + ) + } else { + MainAppScreen() + } } \ 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 index 481522a..0fe24b8 100644 --- a/app/src/main/java/com/samuel/inventorymanager/data/AppSettings.kt +++ b/app/src/main/java/com/samuel/inventorymanager/data/AppSettings.kt @@ -2,14 +2,16 @@ package com.samuel.inventorymanager.data // Theme and appearance settings enum class AppTheme { - LIGHT, DARK, SYSTEM, CUSTOM + LIGHT, DARK, SYSTEM, + DRACULA, VAMPIRE, OCEAN, FOREST, SUNSET, CYBERPUNK, NEON, + CUSTOM } enum class FontSize(val scale: Float) { SMALL(0.85f), MEDIUM(1.0f), - LARGE(1.15f), - EXTRA_LARGE(1.3f) + LARGE(1.3f), + EXTRA_LARGE(1.5f) } data class CustomTheme( @@ -17,38 +19,50 @@ data class CustomTheme( val backgroundColor: Long = 0xFFFFFFFF, val surfaceColor: Long = 0xFFFFFFFF, val onPrimaryColor: Long = 0xFFFFFFFF, - val fontSizeScale: Float = 1.0f // NEW: Font scaling for custom theme + val fontSizeScale: Float = 1.0f ) // OCR Provider enum enum class OCRProvider { - ROBOFLOW, OCR_SPACE, GOOGLE_VISION + TESSERACT_JS, + ROBOFLOW, + OCR_SPACE, + OPTIIC, + GOOGLE_VISION } // AI Provider enum enum class AIProvider { - GOOGLE_GEMINI, OPENAI + GOOGLE_GEMINI, + OPENAI, + SMART_OFFLINE } -// OCR settings with priority +// OCR settings with priority - FIXED to include ALL providers data class OCRSettings( val roboflowApiKey: String = "", val ocrSpaceApiKey: String = "", val googleVisionApiKey: String = "", + val optiicApiKey: String = "", val providerPriority: List = listOf( + OCRProvider.TESSERACT_JS, OCRProvider.ROBOFLOW, OCRProvider.OCR_SPACE, + OCRProvider.OPTIIC, OCRProvider.GOOGLE_VISION ) ) -// AI settings with priority +// AI settings with priority - FIXED to include ALL providers data class AISettings( + + val anthropicApiKey: String = "", // Add this field val googleGeminiApiKey: String = "", val openAIApiKey: String = "", val providerPriority: List = listOf( AIProvider.GOOGLE_GEMINI, - AIProvider.OPENAI + AIProvider.OPENAI, + AIProvider.SMART_OFFLINE ) ) @@ -57,14 +71,14 @@ data class GoogleSettings( val signedIn: Boolean = false, val userEmail: String = "", val autoBackupToDrive: Boolean = false, - val lastBackupTime: Long = 0 // NEW: Track last backup timestamp + val lastBackupTime: Long = 0 ) // Auto features settings data class AutoFeatures( val autoGoogleBackup: Boolean = false, val autoLocalSave: Boolean = true, - val lastLocalSaveTime: Long = 0 // NEW: Track last local save timestamp + val lastLocalSaveTime: Long = 0 ) // COMPLETE AppSettings with ALL properties diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/AIProcessingScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/AIProcessingScreen.kt new file mode 100644 index 0000000..1e7f5fd --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/AIProcessingScreen.kt @@ -0,0 +1,306 @@ +package com.samuel.inventorymanager.screens + +import android.graphics.Bitmap +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.samuel.inventorymanager.services.AIService +import com.samuel.inventorymanager.services.OCRService +import kotlinx.coroutines.delay + +@Composable +fun AIProcessingScreen( + bitmap: Bitmap, + onComplete: (AIAnalysisResult) -> Unit, + onError: (String) -> Unit +) { + val context = LocalContext.current + + var currentStep by remember { mutableStateOf(0) } + var progress by remember { mutableFloatStateOf(0f) } + + val steps = listOf( + "🔍 Analyzing image...", + "📝 Extracting text...", + "🤖 Understanding content...", + "✨ Generating details..." + ) + + // Animate progress + LaunchedEffect(currentStep) { + while (currentStep < steps.size) { + delay(800) + progress = (currentStep + 1) / steps.size.toFloat() + if (currentStep < steps.size - 1) { + currentStep++ + } else { + break + } + } + } + + // Perform actual AI processing + LaunchedEffect(Unit) { + try { + // Step 1: OCR + currentStep = 0 + delay(500) + val ocrService = OCRService(context) + val tempUri = saveBitmapToTempUri(context, bitmap) + val ocrResult = ocrService.performOCR(tempUri) + + // Step 2: AI Analysis + currentStep = 1 + delay(500) + val aiService = AIService(context) + + currentStep = 2 + delay(500) + val aiResult = aiService.analyzeItemFromBitmap(bitmap) + + // Step 3: Combine results + currentStep = 3 + delay(500) + + val finalResult = AIAnalysisResult( + itemName = aiResult.itemName ?: ocrResult.text.lines().firstOrNull(), + modelNumber = aiResult.modelNumber, + description = aiResult.description ?: ocrResult.text, + estimatedPrice = aiResult.estimatedPrice, + dimensions = aiResult.dimensions, + rawText = ocrResult.text + ) + + delay(300) + onComplete(finalResult) + + } catch (e: Exception) { + onError(e.message ?: "AI processing failed") + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF0F172A), + Color(0xFF1E293B), + Color(0xFF312E81) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier.padding(32.dp) + ) { + // Animated AI Icon + AILoadingAnimation() + + // Progress Text + Text( + "AI Magic in Progress", + color = Color.White, + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + + // Current Step + AnimatedContent( + targetState = steps[currentStep], + transitionSpec = { + fadeIn() + slideInVertically { it } togetherWith + fadeOut() + slideOutVertically { -it } + }, + label = "step_animation" + ) { step -> + Text( + step, + color = Color.White.copy(alpha = 0.8f), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } + + // Progress Bar + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = Color(0xFF8B5CF6), + trackColor = Color.White.copy(alpha = 0.2f) + ) + Text( + "${(progress * 100).toInt()}%", + color = Color.White.copy(alpha = 0.6f), + fontSize = 14.sp, + modifier = Modifier.align(Alignment.End) + ) + } + + // Step Indicators + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(top = 16.dp) + ) { + steps.forEachIndexed { index, _ -> + StepIndicator( + isComplete = index <= currentStep, + isActive = index == currentStep + ) + } + } + } + } +} + +@Composable +fun AILoadingAnimation() { + val infiniteTransition = rememberInfiniteTransition(label = "ai_loading") + + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(3000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + + val scale by infiniteTransition.animateFloat( + initialValue = 0.9f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "scale" + ) + + Box( + modifier = Modifier.size(120.dp), + contentAlignment = Alignment.Center + ) { + // Outer rotating circle + Surface( + shape = CircleShape, + color = Color(0xFF8B5CF6).copy(alpha = 0.2f), + modifier = Modifier + .size(120.dp) + .rotate(rotation) + ) {} + + // Inner icon + Surface( + shape = CircleShape, + color = Color(0xFF8B5CF6), + modifier = Modifier + .size(80.dp) + .scale(scale) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Psychology, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + } + } + } +} + +@Composable +fun StepIndicator(isComplete: Boolean, isActive: Boolean) { + val backgroundColor by animateColorAsState( + targetValue = when { + isComplete -> Color(0xFF10B981) + isActive -> Color(0xFF8B5CF6) + else -> Color.White.copy(alpha = 0.2f) + }, + label = "step_bg" + ) + + val size by animateDpAsState( + targetValue = if (isActive) 12.dp else 8.dp, + label = "step_size" + ) + + Surface( + shape = CircleShape, + color = backgroundColor, + modifier = Modifier.size(size) + ) {} +} + +// Helper function +private fun saveBitmapToTempUri(context: android.content.Context, bitmap: Bitmap): android.net.Uri { + val file = java.io.File(context.cacheDir, "temp_ai_${System.currentTimeMillis()}.jpg") + file.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + return android.net.Uri.fromFile(file) +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/AIResultsScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/AIResultsScreen.kt new file mode 100644 index 0000000..8319e13 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/AIResultsScreen.kt @@ -0,0 +1,415 @@ +package com.samuel.inventorymanager.screens + +import android.graphics.Bitmap +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.AspectRatio +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Label +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun AIResultsScreen( + bitmap: Bitmap, + result: AIAnalysisResult, + onBackToEdit: () -> Unit, + onSaveAndContinue: (AIAnalysisResult) -> Unit +) { + var showSuccessAnimation by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(2000) + showSuccessAnimation = false + } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF0F172A), + Color(0xFF1E293B) + ) + ) + ) + ) { + // Top Bar + ResultsTopBar( + onBackToEdit = onBackToEdit, + onSave = { onSaveAndContinue(result) } + ) + + // Content + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Image Preview Card + Card( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + // Success Badge + AnimatedVisibility( + visible = showSuccessAnimation, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() + ) { + SuccessBadge() + } + + // Results Card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B) + ), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + "✨ AI Extracted Details", + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + + HorizontalDivider(color = Color.White.copy(alpha = 0.1f)) + + // Item Name + result.itemName?.let { name -> + ResultField( + icon = Icons.Default.Label, + label = "Item Name", + value = name, + color = Color(0xFF8B5CF6) + ) + } + + // Model Number + result.modelNumber?.let { model -> + ResultField( + icon = Icons.Default.QrCode, + label = "Model Number", + value = model, + color = Color(0xFF10B981) + ) + } + + // Description + result.description?.let { desc -> + ResultField( + icon = Icons.Default.Description, + label = "Description", + value = desc, + color = Color(0xFF3B82F6) + ) + } + + // Dimensions + result.dimensions?.let { dims -> + ResultField( + icon = Icons.Default.AspectRatio, + label = "Dimensions", + value = dims, + color = Color(0xFFEC4899) + ) + } + + // Estimated Price + result.estimatedPrice?.let { price -> + ResultField( + icon = Icons.Default.AttachMoney, + label = "Estimated Value", + value = "$${String.format("%.2f", price)}", + color = Color(0xFF10B981) + ) + } + } + } + + // Detected Text Card (if available) + result.rawText?.takeIf { it.isNotBlank() }?.let { text -> + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B).copy(alpha = 0.6f) + ) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.TextFields, + contentDescription = null, + tint = Color(0xFF8B5CF6), + modifier = Modifier.size(24.dp) + ) + Text( + "Detected Text", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + Text( + text, + color = Color.White.copy(alpha = 0.8f), + fontSize = 14.sp, + lineHeight = 20.sp + ) + } + } + } + + // Action Buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onBackToEdit, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(16.dp), + border = androidx.compose.foundation.BorderStroke( + 2.dp, + Color.White.copy(alpha = 0.3f) + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ) + ) { + Icon(Icons.Default.Edit, null) + Spacer(Modifier.width(8.dp)) + Text("Edit Photo", fontWeight = FontWeight.Bold) + } + + Button( + onClick = { onSaveAndContinue(result) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF10B981) + ) + ) { + Icon(Icons.Default.Check, null) + Spacer(Modifier.width(8.dp)) + Text("Save Item", fontWeight = FontWeight.Bold) + } + } + } + } + } +} + +@Composable +fun ResultsTopBar(onBackToEdit: () -> Unit, onSave: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xFF1E293B).copy(alpha = 0.95f), + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBackToEdit, + modifier = Modifier + .size(48.dp) + .background(Color.White.copy(alpha = 0.1f), CircleShape) + ) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back to edit", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + + Text( + "🎯 AI Results", + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + IconButton( + onClick = onSave, + modifier = Modifier + .size(48.dp) + .background(Color(0xFF10B981), CircleShape) + ) { + Icon( + Icons.Default.Check, + contentDescription = "Save", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Composable +fun SuccessBadge() { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF10B981).copy(alpha = 0.2f) + ), + border = androidx.compose.foundation.BorderStroke(2.dp, Color(0xFF10B981)) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + shape = CircleShape, + color = Color(0xFF10B981), + modifier = Modifier.size(48.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + Text( + "AI analysis complete! Review the details below.", + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Composable +fun ResultField( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String, + color: Color +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(20.dp) + ) + Text( + label, + color = Color.White.copy(alpha = 0.6f), + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + } + Surface( + shape = RoundedCornerShape(12.dp), + color = color.copy(alpha = 0.1f), + border = androidx.compose.foundation.BorderStroke(1.dp, color.copy(alpha = 0.3f)) + ) { + Text( + value, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + } + } +} \ 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 index 5c8f960..815cdc8 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemScreen.kt @@ -3,10 +3,17 @@ package com.samuel.inventorymanager.screens import android.Manifest import android.content.Context import android.net.Uri +import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -18,36 +25,47 @@ 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.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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape 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.Add +import androidx.compose.material.icons.filled.CameraAlt 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.Delete +import androidx.compose.material.icons.filled.DocumentScanner import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.SaveAs +import androidx.compose.material.icons.filled.Update 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.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 @@ -56,7 +74,6 @@ 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 @@ -68,29 +85,36 @@ 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.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer 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.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.content.FileProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.rememberAsyncImagePainter import com.samuel.inventorymanager.data.AppSettings +import com.samuel.inventorymanager.services.AIService +import com.samuel.inventorymanager.services.OCRService 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( + items: List, garages: List, onSaveItem: (Item) -> Unit, + onUpdateItem: (Item) -> Unit, + onDeleteItem: (Item) -> Unit, viewModel: CreateItemViewModel = viewModel(), appSettings: AppSettings, onSettingsChange: (AppSettings) -> Unit @@ -99,11 +123,27 @@ fun CreateItemScreen( val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + // Image Processing States + var showImageProcessing by remember { mutableStateOf(false) } + var capturedImageUri by remember { mutableStateOf(null) } + var tempCameraUri by remember { mutableStateOf(null) } + + // Dialog States var showUnsavedWarning by remember { mutableStateOf(false) } + var showDuplicateDialog by remember { mutableStateOf(false) } + var showDeleteConfirmDialog by remember { mutableStateOf(false) } + var duplicateItem by remember { mutableStateOf(null) } var showCameraPreferenceBanner by remember { mutableStateOf(false) } + + // Processing States + var isProcessingOCR by remember { mutableStateOf(false) } + var isProcessingAI by remember { mutableStateOf(false) } + + // Auto-save var autoSaveEnabled by remember { mutableStateOf(true) } var lastAutoSaveTime by remember { mutableLongStateOf(0L) } + // Dynamic options based on selection 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() @@ -120,51 +160,111 @@ fun CreateItemScreen( ?.boxes?.map { it.name } ?: emptyList() } - var tempCameraUri by remember { mutableStateOf(null) } + // ==================== FUNCTIONS: Helpers ==================== + fun createImageUri(context: Context): Uri { + val directory = File(context.cacheDir, "images") + if (!directory.exists()) directory.mkdirs() + val file = File(directory, "IMG_${System.currentTimeMillis()}.jpg") + return FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + } - val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> - if (success) { - tempCameraUri?.let { - viewModel.imageUris.add(it) - viewModel.checkForChanges() - } + // ==================== LAUNCHERS ==================== + + val cameraLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.TakePicture() + ) { success -> + if (success && tempCameraUri != null) { + capturedImageUri = tempCameraUri + showImageProcessing = true } } - val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> if (isGranted) { val uri = createImageUri(context) tempCameraUri = uri + capturedImageUri = uri cameraLauncher.launch(uri) + } else { + scope.launch { snackbarHostState.showSnackbar("📷 Camera permission required") } } } - val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> - viewModel.imageUris.addAll(uris) - viewModel.checkForChanges() + val galleryLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isNotEmpty()) { + viewModel.imageUris.addAll(uris) + viewModel.checkForChanges() + scope.launch { snackbarHostState.showSnackbar("✅ ${uris.size} image(s) added") } + } } + // ==================== FUNCTIONS ==================== + fun launchCameraWithPermissionCheck() { permissionLauncher.launch(Manifest.permission.CAMERA) } + fun findDuplicateItem(): Item? { + val itemName = viewModel.itemName.trim() + val modelNumber = viewModel.modelNumber.trim() + + return items.find { existingItem -> + val nameMatch = existingItem.name.equals(itemName, ignoreCase = true) + val modelMatch = if (modelNumber.isNotBlank() && existingItem.modelNumber != null) { + existingItem.modelNumber.equals(modelNumber, ignoreCase = true) + } else false + + nameMatch || modelMatch + } + } + fun saveItem(showNotification: Boolean = true) { if (viewModel.itemName.isBlank()) { scope.launch { snackbarHostState.showSnackbar("⚠️ Item name is required") } return } + + if (viewModel.selectedGarageName.isBlank()) { + scope.launch { snackbarHostState.showSnackbar("⚠️ You must select a Garage!") } + return + } + + val duplicate = findDuplicateItem() + if (duplicate != null) { + duplicateItem = duplicate + showDuplicateDialog = true + return + } + onSaveItem(viewModel.getItemToSave(garages)) viewModel.markAsSaved() if (showNotification) { - scope.launch { snackbarHostState.showSnackbar("✓ Item saved successfully!") } + scope.launch { snackbarHostState.showSnackbar("✅ Item created successfully!") } } } - fun handleNewItemAndCamera() { + fun updateExistingItem(item: Item) { + val updatedItem = viewModel.getItemToSave(garages).copy(id = item.id) + onUpdateItem(updatedItem) + viewModel.markAsSaved() + scope.launch { snackbarHostState.showSnackbar("✅ Item updated successfully!") } + } + + fun createNewItemAnyway() { + onSaveItem(viewModel.getItemToSave(garages)) + viewModel.markAsSaved() + scope.launch { snackbarHostState.showSnackbar("✅ New item created!") } + } + + fun handleNewItemClick() { if (viewModel.hasUnsavedChanges) { showUnsavedWarning = true } else { - viewModel.clearFormForNewItem(garages) + viewModel.clearFormForNewItem() if (appSettings.openCameraOnNewItem) { if (!appSettings.hasShownCameraPreference) { showCameraPreferenceBanner = true @@ -175,6 +275,90 @@ fun CreateItemScreen( } } + fun performOCR() { + if (viewModel.imageUris.isEmpty()) { + scope.launch { snackbarHostState.showSnackbar("⚠️ Please add an image first!") } + return + } + + scope.launch { + isProcessingOCR = true + try { + val ocrService = OCRService(context) + val result = ocrService.performOCR(viewModel.imageUris.first(), appSettings.ocrSettings) + + val lines = result.text.lines().filter { it.isNotBlank() } + if (lines.isNotEmpty()) { + viewModel.itemName = lines.firstOrNull() ?: "" + if (lines.size > 1) viewModel.modelNumber = lines[1] + if (lines.size > 2) viewModel.description = lines.drop(2).joinToString("\n") + } + + snackbarHostState.showSnackbar("✅ OCR Complete! (${result.provider})") + viewModel.checkForChanges() + } catch (e: Exception) { + snackbarHostState.showSnackbar("❌ OCR Failed: ${e.message}") + } finally { + isProcessingOCR = false + } + } + } + + fun performAI() { + if (viewModel.imageUris.isEmpty() && viewModel.itemName.isBlank()) { + scope.launch { snackbarHostState.showSnackbar("⚠️ Please add an image or item name first!") } + return + } + scope.launch { + isProcessingAI = true + try { + val aiService = AIService(context) + val result: AIService.AIAnalysisResult = + if (viewModel.imageUris.isNotEmpty()) { + aiService.analyzeItemFromBitmap( + android.graphics.BitmapFactory.decodeStream( + context.contentResolver.openInputStream(viewModel.imageUris.first()) + )!! + ) + } else { + // Fixed: Provide all required parameters + AIService.AIAnalysisResult( + itemName = null, + confidence = 0.0, + modelNumber = null, + description = "Please add an image for AI analysis", + estimatedPrice = null, + condition = null, + sizeCategory = null, + dimensions = null, + rawText = null + ) + } + result.itemName?.let { viewModel.itemName = it } + result.modelNumber?.let { viewModel.modelNumber = it } + result.description?.let { viewModel.description = it } + result.condition?.let { viewModel.condition = it } + result.sizeCategory?.let { viewModel.sizeCategory = it } + result.estimatedPrice?.let { price -> + viewModel.minPrice = price.toString() + viewModel.maxPrice = (price * 1.2).toString() + } + result.dimensions?.let { viewModel.dimensions = it } + + snackbarHostState.showSnackbar("✅ AI Analysis Complete!") + viewModel.checkForChanges() + } catch (e: Exception) { + Log.e("performAI", "AI processing failed", e) + snackbarHostState.showSnackbar("AI processing failed: ${e.localizedMessage}") + } finally { + isProcessingAI = false + } + } + } + + + // ==================== AUTO-SAVE ==================== + LaunchedEffect( viewModel.itemName, viewModel.modelNumber, viewModel.description, viewModel.webLink, viewModel.condition, viewModel.functionality, viewModel.quantity, viewModel.minPrice, @@ -185,421 +369,839 @@ fun CreateItemScreen( viewModel.checkForChanges() if (autoSaveEnabled && viewModel.hasUnsavedChanges && viewModel.itemName.isNotBlank()) { val currentTime = System.currentTimeMillis() - if (currentTime - lastAutoSaveTime > 3000) { - delay(3000) + if (currentTime - lastAutoSaveTime > 8000) { + delay(2000) saveItem(false) lastAutoSaveTime = currentTime - scope.launch { snackbarHostState.showSnackbar("💾 Auto-saved", withDismissAction = true) } + scope.launch { + snackbarHostState.showSnackbar("💾 Auto-saved", withDismissAction = true) + } } } } + // ==================== IMAGE PROCESSING DIALOG ==================== + + if (showImageProcessing && capturedImageUri != null) { + Dialog( + onDismissRequest = { showImageProcessing = false }, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false) + ) { + ImageProcessingScreen( + imageUri = capturedImageUri!!, + onImageProcessed = { uri, aiResult -> + viewModel.imageUris.add(uri) + aiResult.itemName?.let { viewModel.itemName = it } + aiResult.modelNumber?.let { viewModel.modelNumber = it } + aiResult.description?.let { + viewModel.description = if (viewModel.description.isBlank()) it else "${viewModel.description}\n\n$it" + } + aiResult.condition?.let { viewModel.condition = it } + aiResult.sizeCategory?.let { viewModel.sizeCategory = it } + aiResult.estimatedPrice?.let { + viewModel.minPrice = it.toString() + viewModel.maxPrice = (it * 1.2).toString() + } + viewModel.checkForChanges() + showImageProcessing = false + scope.launch { snackbarHostState.showSnackbar("✨ AI auto-filled item details!") } + }, + onCancel = { showImageProcessing = false }, + aiService = AIService(context) + ) + } + } + + // ==================== DUPLICATE DIALOG ==================== + + if (showDuplicateDialog && duplicateItem != null) { + ModernAlertDialog( + onDismissRequest = { showDuplicateDialog = false }, + icon = Icons.Default.Warning, + iconTint = Color(0xFFFBBF24), + title = "Duplicate Item Found", + content = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "An item with this name already exists:", + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp + ) + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2A2A)), + shape = RoundedCornerShape(12.dp) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + InfoRow("Name:", duplicateItem!!.name) + InfoRow("Qty:", "${duplicateItem!!.quantity}") + InfoRow("Condition:", duplicateItem!!.condition) + } + } + Text( + "Would you like to update this item or create a new one?", + color = Color.White.copy(alpha = 0.6f), + fontSize = 13.sp + ) + } + }, + confirmButton = { + Button( + onClick = { + updateExistingItem(duplicateItem!!) + showDuplicateDialog = false + duplicateItem = null + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Update, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Update Existing", fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { showDuplicateDialog = false; duplicateItem = null }) { + Text("Cancel", color = Color.White.copy(alpha = 0.7f)) + } + Button( + onClick = { + createNewItemAnyway() + showDuplicateDialog = false + duplicateItem = null + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF10B981)), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Create New", fontWeight = FontWeight.Bold) + } + } + } + ) + } + + // ==================== UNSAVED WARNING DIALOG ==================== + if (showUnsavedWarning) { - AlertDialog( + ModernAlertDialog( 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?") }, + icon = Icons.Default.Warning, + iconTint = Color(0xFFFBBF24), + title = "Unsaved Changes", + content = { + Text( + "You have unsaved changes. Do you want to save before creating a new item?", + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp + ) + }, confirmButton = { - Button(onClick = { - saveItem() - viewModel.clearFormForNewItem(garages) - showUnsavedWarning = false - if (appSettings.openCameraOnNewItem) { - launchCameraWithPermissionCheck() - } - }) { - Text("Save & Continue") + Button( + onClick = { + saveItem() + viewModel.clearFormForNewItem() + showUnsavedWarning = false + if (appSettings.openCameraOnNewItem) launchCameraWithPermissionCheck() + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)), + shape = RoundedCornerShape(12.dp) + ) { + Text("Save & Continue", fontWeight = FontWeight.Bold) } }, dismissButton = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton({ showUnsavedWarning = false }) { Text("Cancel") } + TextButton({ showUnsavedWarning = false }) { + Text("Cancel", color = Color.White.copy(alpha = 0.7f)) + } TextButton( onClick = { - viewModel.clearFormForNewItem(garages) + viewModel.clearFormForNewItem() showUnsavedWarning = false - if (appSettings.openCameraOnNewItem) { - launchCameraWithPermissionCheck() - } - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) + if (appSettings.openCameraOnNewItem) launchCameraWithPermissionCheck() + } ) { - Text("Discard") + Text("Discard", color = Color(0xFFEF4444), fontWeight = FontWeight.Bold) } } } ) } - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + // ==================== DELETE CONFIRMATION DIALOG ==================== + + if (showDeleteConfirmDialog) { + ModernAlertDialog( + onDismissRequest = { showDeleteConfirmDialog = false }, + icon = Icons.Default.Delete, + iconTint = Color(0xFFEF4444), + title = "Delete This Item?", + content = { + Text( + "This will permanently delete this item from your inventory. This action cannot be undone.", + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp + ) + }, + confirmButton = { + Button( + onClick = { + val itemToDelete = viewModel.getItemToSave(garages) + onDeleteItem(itemToDelete) + viewModel.clearFormForNewItem() + showDeleteConfirmDialog = false + scope.launch { snackbarHostState.showSnackbar("🗑️ Item deleted successfully") } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444)), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Delete Permanently", fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmDialog = false }) { + Text("Cancel", color = Color.White.copy(alpha = 0.7f)) + } + } + ) + } + + // ==================== MAIN UI ==================== + + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) { Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()) - .padding(bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(bottom = 200.dp) ) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { + // Header with gradient + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .background( + Brush.verticalGradient( + colors = listOf(Color(0xFF6366F1), Color(0xFF8B5CF6)) + ) + ) + ) + Column(modifier = Modifier.padding(20.dp)) { Text( - "Create / Edit Item", - style = MaterialTheme.typography.headlineMedium, + "Create Item", + color = Color.White, + fontSize = 32.sp, 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) + Spacer(Modifier.height(8.dp)) + Text( + "Add new items to your inventory", + color = Color.White.copy(alpha = 0.8f), + fontSize = 14.sp ) - Text("Auto-save: ${if (autoSaveEnabled) "ON" else "OFF"}", modifier = Modifier.padding(start = 4.dp)) + + Spacer(Modifier.height(20.dp)) + + // Status card + StatusCard(viewModel = viewModel) } } + // Camera Preference Banner 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 + CameraPreferenceBanner( + onDismiss = { showCameraPreferenceBanner = false } ) } - 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 - } - } + Spacer(Modifier.height(16.dp)) - ModernCard { - SectionHeader("📊 Attributes") - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + // Main Content + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Core Details + ModernCard(title = "📝 Item Details") { ModernTextField( - value = viewModel.quantity, - onValueChange = { viewModel.quantity = it }, - label = "Quantity", - keyboardType = KeyboardType.Number, - modifier = Modifier.weight(1f) + value = viewModel.itemName, + onValueChange = { viewModel.itemName = it }, + label = "Item Name *", + leadingIcon = Icons.Default.Edit ) ModernTextField( - value = viewModel.weight, - onValueChange = { viewModel.weight = it }, - label = "Weight (lbs)", - keyboardType = KeyboardType.Decimal, - modifier = Modifier.weight(1f) + 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", "Like New", "Good", "Fair", "Poor"), + viewModel.condition + ) { viewModel.condition = it } + } + Box(Modifier.weight(1f)) { + DropdownField( + "Status", + listOf("Fully Functional", "Partially Functional", "Not Functional", "Needs Testing"), + viewModel.functionality + ) { viewModel.functionality = it } + } + } } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ModernTextField( - value = viewModel.minPrice, - onValueChange = { viewModel.minPrice = it }, - label = "Min Price ($)", - keyboardType = KeyboardType.Decimal, - modifier = Modifier.weight(1f) - ) + + // Description + ModernCard(title = "📄 Description") { ModernTextField( - value = viewModel.maxPrice, - onValueChange = { viewModel.maxPrice = it }, - label = "Max Price ($)", - keyboardType = KeyboardType.Decimal, - modifier = Modifier.weight(1f) + value = viewModel.description, + onValueChange = { viewModel.description = it }, + label = "Description / Notes", + singleLine = false, + modifier = Modifier.height(120.dp), + leadingIcon = Icons.AutoMirrored.Filled.Notes ) } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Box(Modifier.weight(1f)) { - DropdownField( - "Size", - listOf("Small", "Medium", "Large"), - viewModel.sizeCategory - ) { viewModel.sizeCategory = it } + + // Location + ModernCard(title = "📍 Storage 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 } - 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" - ) - } + // Quantity & Physical Attributes + ModernCard(title = "📦 Quantity & 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)) { + Box(Modifier.weight(1f)) { + DropdownField( + "Size", + listOf("Small", "Medium", "Large", "Extra Large"), + viewModel.sizeCategory + ) { viewModel.sizeCategory = it } + } + ModernTextField( + value = viewModel.dimensions, + onValueChange = { viewModel.dimensions = it }, + label = "Dimensions (LxWxH)", + modifier = Modifier.weight(1f) + ) + } + } - 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") } + // Pricing + ModernCard(title = "💰 Pricing") { + 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(8.dp)) { - OutlinedButton( - {}, - Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { Text("📄 OCR") } - OutlinedButton( - {}, - Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { Text("🤖 AI ID") } + + // Web Link + ModernCard(title = "🔗 Product Link") { + ModernTextField( + value = viewModel.webLink, + onValueChange = { viewModel.webLink = it }, + label = "Web Link / Product URL" + ) } - 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) + + // Images + ModernCard(title = "📸 Images") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + { launchCameraWithPermissionCheck() }, + Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(16.dp) + ) { + Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("Camera", fontWeight = FontWeight.Bold) + } + Button( + { galleryLauncher.launch("image/*") }, + Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8B5CF6)), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(16.dp) + ) { + Text("📁 Upload", fontWeight = FontWeight.Bold) + } + } + + if (viewModel.imageUris.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text( + "${viewModel.imageUris.size} image(s) attached", + color = Color.White.copy(alpha = 0.6f), + fontSize = 12.sp + ) + Row( + Modifier + .horizontalScroll(rememberScrollState()) + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + viewModel.imageUris.forEach { uri -> + Box { + Card( + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Image( + rememberAsyncImagePainter(uri), + "Selected", + Modifier.size(120.dp) + ) + } + IconButton( + onClick = { + viewModel.imageUris.remove(uri) + viewModel.checkForChanges() + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .background(Color(0xFFEF4444), CircleShape) + .size(32.dp) + ) { + Icon( + Icons.Default.Close, + "Delete", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } } } } } } + + Spacer(Modifier.height(20.dp)) } } - Card( - modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter), - shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), - elevation = CardDefaults.cardElevation(8.dp) + // Loading Overlay + if (isProcessingOCR || isProcessingAI) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.85f)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + val infiniteTransition = rememberInfiniteTransition(label = "processing_pulse") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "scale" + ) + + Icon( + if (isProcessingOCR) Icons.Default.DocumentScanner else Icons.Default.Psychology, + contentDescription = null, + modifier = Modifier + .size(80.dp) + .graphicsLayer(scaleX = scale, scaleY = scale), + tint = if (isProcessingOCR) Color(0xFF6366F1) else Color(0xFF8B5CF6) + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + if (isProcessingOCR) "📸 Processing OCR..." else "🤖 Processing AI...", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + Text( + if (isProcessingOCR) "Extracting text from image" else "Analyzing item details", + color = Color.White.copy(alpha = 0.6f), + fontSize = 14.sp + ) + } + + CircularProgressIndicator( + color = if (isProcessingOCR) Color(0xFF6366F1) else Color(0xFF8B5CF6), + modifier = Modifier.size(48.dp) + ) + } + } + } + + // Bottom Action Bar + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + shadowElevation = 12.dp, + color = Color(0xFF1A1A1A) ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Button( - onClick = ::handleNewItemAndCamera, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary - ), - shape = RoundedCornerShape(12.dp) + // OCR and AI buttons row + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() ) { - Text("➕ New Item") + Button( + onClick = { performOCR() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(16.dp) + ) { + Icon(Icons.Default.DocumentScanner, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("OCR Scan", fontSize = 15.sp, fontWeight = FontWeight.Bold) + } + Button( + onClick = { performAI() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8B5CF6)), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(16.dp) + ) { + Icon(Icons.Default.Psychology, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("AI Fill", fontSize = 15.sp, fontWeight = FontWeight.Bold) + } } - Button( - onClick = { saveItem() }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - enabled = viewModel.itemName.isNotBlank() + + // Main action buttons row + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() ) { - 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)) + Button( + onClick = { handleNewItemClick() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2A2A2A)), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(16.dp) + ) { + Icon(Icons.Default.Edit, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(6.dp)) + Text("New", fontSize = 15.sp, fontWeight = FontWeight.Bold) + } + Button( + onClick = { saveItem() }, + modifier = Modifier.weight(1.2f), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF10B981)), + contentPadding = PaddingValues(16.dp), + enabled = viewModel.itemName.isNotBlank() && viewModel.selectedGarageName.isNotBlank() + ) { + val scale by animateFloatAsState( + if (viewModel.hasUnsavedChanges) 1.15f else 1f, + label = "save_scale" + ) + Icon( + Icons.Default.SaveAs, + "Save", + modifier = Modifier + .scale(scale) + .size(20.dp) + ) + Spacer(Modifier.width(6.dp)) + Text("Save", fontSize = 15.sp, fontWeight = FontWeight.Bold) + } + Button( + onClick = { showDeleteConfirmDialog = true }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444)), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(16.dp) + ) { + Icon(Icons.Default.Delete, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(6.dp)) + Text("Delete", fontSize = 15.sp, fontWeight = FontWeight.Bold) + } } } } + // Snackbar at BOTTOM SnackbarHost( hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 90.dp) + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 180.dp, start = 16.dp, end = 16.dp) ) { data -> Snackbar( snackbarData = data, - shape = RoundedCornerShape(12.dp), - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + shape = RoundedCornerShape(14.dp), + containerColor = Color(0xFF2A2A2A), + contentColor = Color.White ) } } } } +// ==================== COMPOSABLE COMPONENTS ==================== + +@Composable +private fun StatusCard(viewModel: CreateItemViewModel) { + val isNameEmpty = viewModel.itemName.isBlank() + val isLocationEmpty = viewModel.selectedGarageName.isBlank() + + when { + isNameEmpty || isLocationEmpty -> { + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFFEF4444).copy(alpha = 0.2f)), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Warning, + "Warning", + tint = Color(0xFFEF4444), + modifier = Modifier.size(28.dp) + ) + Column { + Text( + "⚠️ Required Fields Missing", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Text( + if (isNameEmpty && isLocationEmpty) "Item name and location required" + else if (isNameEmpty) "Item name required" + else "Storage location required", + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp + ) + } + } + } + } + viewModel.hasUnsavedChanges -> { + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFFFBBF24).copy(alpha = 0.2f)), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Edit, + "Unsaved", + tint = Color(0xFFFBBF24), + modifier = Modifier.size(28.dp) + ) + Column { + Text( + "📝 Unsaved Changes", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Text( + "Don't forget to save your work", + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp + ) + } + } + } + } + else -> { + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFF10B981).copy(alpha = 0.2f)), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Check, + "Saved", + tint = Color(0xFF10B981), + modifier = Modifier.size(28.dp) + ) + Column { + Text( + "✅ All Saved", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Text( + "Your item is up to date", + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp + ) + } + } + } + } + } +} + @Composable -fun ModernCard(content: @Composable ColumnScope.() -> Unit) { +private fun CameraPreferenceBanner( + onDismiss: () -> Unit +) { Card( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(3.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - ), + colors = CardDefaults.cardColors(containerColor = Color(0xFF6366F1).copy(alpha = 0.2f)), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), shape = RoundedCornerShape(16.dp) ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - content = content - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Info, + null, + tint = Color(0xFF6366F1), + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + "📸 Auto-Camera Active", + fontWeight = FontWeight.Bold, + color = Color.White, + fontSize = 14.sp + ) + Text( + "Camera opens on 'New Item'", + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.7f) + ) + } + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, null, tint = Color.White, modifier = Modifier.size(20.dp)) + } + } } } @Composable -fun SectionHeader(text: String) { - Text( - text, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) +fun ModernCard( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A)), + shape = RoundedCornerShape(20.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + title, + color = Color(0xFF6366F1), + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + content() + } + } } @Composable @@ -615,15 +1217,20 @@ fun ModernTextField( OutlinedTextField( value = value, onValueChange = onValueChange, - label = { Text(label) }, - leadingIcon = leadingIcon?.let { { Icon(it, null) } }, + label = { Text(label, fontSize = 13.sp) }, + leadingIcon = leadingIcon?.let { { Icon(it, null, modifier = Modifier.size(20.dp), tint = Color(0xFF6366F1)) } }, 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) + focusedBorderColor = Color(0xFF6366F1), + unfocusedBorderColor = Color(0xFF3A3A3A), + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + cursorColor = Color(0xFF6366F1), + focusedLabelColor = Color(0xFF6366F1), + unfocusedLabelColor = Color.White.copy(alpha = 0.6f) ) ) } @@ -639,39 +1246,94 @@ fun DropdownField( var isExpanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( expanded = isExpanded, - onExpandedChange = { isExpanded = it } + onExpandedChange = { isExpanded = !isExpanded } ) { OutlinedTextField( value = selectedValue.ifEmpty { "Select..." }, onValueChange = {}, readOnly = true, - label = { Text(label) }, + label = { Text(label, fontSize = 13.sp) }, 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) - ) + focusedBorderColor = Color(0xFF6366F1), + unfocusedBorderColor = Color(0xFF3A3A3A), + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedLabelColor = Color(0xFF6366F1), + unfocusedLabelColor = Color.White.copy(alpha = 0.6f) + ), + modifier = Modifier + .fillMaxWidth() + .menuAnchor() ) + ExposedDropdownMenu( expanded = isExpanded, - onDismissRequest = { isExpanded = false } + onDismissRequest = { isExpanded = false }, + modifier = Modifier.background(Color(0xFF2A2A2A)) ) { options.forEach { option -> DropdownMenuItem( - text = { Text(option) }, + text = { Text(option, fontSize = 13.sp, color = Color.White) }, onClick = { onValueChange(option) isExpanded = false - } + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding ) } } } } -fun createImageUri(context: Context): Uri { - val imageFile = File(context.cacheDir, "${UUID.randomUUID()}.jpg") - return FileProvider.getUriForFile(context, "${context.packageName}.provider", imageFile) +@Composable +fun ModernAlertDialog( + onDismissRequest: () -> Unit, + icon: ImageVector, + iconTint: Color, + title: String, + content: @Composable () -> Unit, + confirmButton: @Composable () -> Unit, + dismissButton: @Composable () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon( + icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text(title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 20.sp) + }, + text = content, + confirmButton = confirmButton, + dismissButton = dismissButton, + containerColor = Color(0xFF1A1A1A), + shape = RoundedCornerShape(20.dp) + ) +} + +@Composable +fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + label, + fontWeight = FontWeight.Bold, + color = Color.White.copy(alpha = 0.6f), + fontSize = 13.sp + ) + Text( + value, + color = Color.White, + fontSize = 13.sp + ) + } } \ 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 index 4b0a2f5..719f881 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemVewModel.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/CreateItemVewModel.kt @@ -1,60 +1,209 @@ package com.samuel.inventorymanager.screens +import android.app.Application import android.net.Uri +import android.util.Log 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 androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.samuel.inventorymanager.services.OCRService +import kotlinx.coroutines.launch import java.util.UUID -class CreateItemViewModel : ViewModel() { +// --- IMPORTANT: Import your Data Models here --- +// If your Item/Garage classes are in 'com.samuel.inventorymanager.data', keep these. +// If they are in 'models', change 'data' to 'models'. +//import com.samuel.inventorymanager.data.Garage +//import com.samuel.inventorymanager.data.Item - // Form fields - all mutable state +// --- Helper Data Class for AI Results --- +data class AIAnalysisResult( + val itemName: String? = null, + val confidence: Double = 0.0, + val modelNumber: String? = null, + val description: String? = null, + val estimatedPrice: Double? = null, + val condition: String? = null, + val sizeCategory: String? = null, + val dimensions: String? = null, + val rawText: String? = null +) + +class CreateItemViewModel( + application: Application, + private val ocrService: OCRService +) : AndroidViewModel(application) { + + // --- Core Item Data --- + var currentItem: Item? by mutableStateOf(null) + + // --- UI Form 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 condition by mutableStateOf("Good") + var functionality by mutableStateOf("Working") + var quantity by mutableStateOf("1") var minPrice by mutableStateOf("") var maxPrice by mutableStateOf("") var weight by mutableStateOf("") - var sizeCategory by mutableStateOf("") + var sizeCategory by mutableStateOf("Medium") var dimensions by mutableStateOf("") - // Location fields + // --- Location State --- var selectedGarageName by mutableStateOf("") var selectedCabinetName by mutableStateOf("") var selectedShelfName by mutableStateOf("") var selectedBoxName by mutableStateOf(null) - // Images + // --- Image & AI State --- val imageUris = mutableStateListOf() + var isProcessing by mutableStateOf(false) + var aiAnalysisResult by mutableStateOf(null) + var showAIPreview by mutableStateOf(false) - // Change tracking + // --- Change Tracking --- var hasUnsavedChanges by mutableStateOf(false) private set - private var lastSavedState: Int = 0 + private var lastSavedHash: Int = 0 init { - lastSavedState = getCurrentStateHash() + markAsSaved() } - fun checkForChanges() { - hasUnsavedChanges = getCurrentStateHash() != lastSavedState + // ================================================================================= + // 1. SMART AI/OCR FUNCTIONALITY + // ================================================================================= + + /** + * UI should call this when an image is captured. + */ + @Suppress("unused") // Called from UI + fun analyzeImage(imageUri: Uri) { + isProcessing = true + + viewModelScope.launch { + try { + // 1. Run OCR + val result = ocrService.performOCR(imageUri) + + // 2. Smart Parse + val analyzedData = smartParseOCRText(result.text) + + // 3. Update Preview + aiAnalysisResult = analyzedData + + // Add to images if new + if (!imageUris.contains(imageUri)) { + imageUris.add(imageUri) + } + + showAIPreview = true + } catch (e: Exception) { + Log.e("CreateItemVM", "AI Analysis failed: ${e.message}") + } finally { + isProcessing = false + } + } + } + + + /** + * Helper to parse raw text into structured data. + */ + private fun smartParseOCRText(rawText: String): AIAnalysisResult { + val lines = rawText.lines() + + // Guess Model Number (uppercase + numbers mixed, >3 chars) + val modelRegex = Regex("\\b(?=.*[A-Z])(?=.*\\d)[A-Z\\d-]{4,}\\b") + val possibleModel = lines.firstNotNullOfOrNull { line -> + modelRegex.find(line)?.value + } + + // Guess Dimensions (Num x Num or NumxNum) + val dimRegex = Regex("\\d+(\\.\\d+)?\\s*[xX]\\s*\\d+(\\.\\d+)?(\\s*[xX]\\s*\\d+(\\.\\d+)?)?") + val possibleDimensions = lines.firstNotNullOfOrNull { line -> + dimRegex.find(line)?.value + } + + // Guess Price + val priceRegex = Regex("\\$\\s*([0-9,]+(\\.\\d{2})?)") + val priceString = lines.firstNotNullOfOrNull { line -> + priceRegex.find(line)?.groupValues?.get(1) + }?.replace(",", "") + val priceVal = priceString?.toDoubleOrNull() + + // Name (simple guess: first reasonably long line without a '$') + val possibleName = lines.firstOrNull { it.length > 4 && !it.contains("$") } + + return AIAnalysisResult( + itemName = possibleName ?: "", + confidence = 0.5, // Add confidence estimate + modelNumber = possibleModel, + description = rawText.take(200), + estimatedPrice = priceVal, + dimensions = possibleDimensions, + rawText = rawText, + condition = null, + sizeCategory = null + ) + } + + /** + * Called when user confirms AI preview. + */ + @Suppress("unused") // Called from UI + fun applyAIResultToForm(result: AIAnalysisResult) { + if (itemName.isBlank()) result.itemName?.let { itemName = it } + if (modelNumber.isBlank()) result.modelNumber?.let { modelNumber = it } + + // Combine description logic + if (!result.rawText.isNullOrBlank()) { + description = if (description.isBlank()) { + result.rawText + } else { + "$description\n\n--- Scanned Data ---\n${result.rawText}" + } + } + + if (dimensions.isBlank()) result.dimensions?.let { dimensions = it } + + result.estimatedPrice?.let { p -> + if (minPrice.isBlank()) minPrice = p.toString() + if (maxPrice.isBlank()) maxPrice = (p * 1.2).toString() + } + + checkForChanges() + showAIPreview = false } + + // ================================================================================= + // 2. CRUD LOGIC + // ================================================================================= + private fun getCurrentStateHash(): Int { return listOf( itemName, modelNumber, description, webLink, condition, functionality, quantity, minPrice, maxPrice, weight, sizeCategory, dimensions, selectedGarageName, selectedCabinetName, selectedShelfName, selectedBoxName, - imageUris.size + imageUris.toList().map { it.toString() } ).hashCode() } + fun checkForChanges() { + hasUnsavedChanges = getCurrentStateHash() != lastSavedHash + } + + fun markAsSaved() { + lastSavedHash = getCurrentStateHash() + hasUnsavedChanges = false + } + fun getItemToSave(garages: List): Item { val garage = garages.find { it.name == selectedGarageName } val cabinet = garage?.cabinets?.find { it.name == selectedCabinetName } @@ -62,7 +211,7 @@ class CreateItemViewModel : ViewModel() { val box = shelf?.boxes?.find { it.name == selectedBoxName } return Item( - id = UUID.randomUUID().toString(), + id = currentItem?.id ?: UUID.randomUUID().toString(), name = itemName, modelNumber = modelNumber.ifBlank { null }, description = description.ifBlank { null }, @@ -83,39 +232,35 @@ class CreateItemViewModel : ViewModel() { ) } - fun markAsSaved() { - lastSavedState = getCurrentStateHash() - hasUnsavedChanges = false - } - - fun clearFormForNewItem(garages: List) { + fun clearFormForNewItem() { + currentItem = null itemName = "" modelNumber = "" description = "" webLink = "" - condition = "" - functionality = "" - quantity = "" + condition = "Good" + functionality = "Working" + quantity = "1" minPrice = "" maxPrice = "" weight = "" - sizeCategory = "" + sizeCategory = "Medium" dimensions = "" imageUris.clear() - // Keep location if valid, otherwise reset - if (garages.none { it.name == selectedGarageName }) { - selectedGarageName = "" - selectedCabinetName = "" - selectedShelfName = "" - selectedBoxName = null - } + // Reset Location state logic (Optional: keep location if rapid adding?) + selectedGarageName = "" + selectedCabinetName = "" + selectedShelfName = "" + selectedBoxName = null - hasUnsavedChanges = false - lastSavedState = getCurrentStateHash() + aiAnalysisResult = null + markAsSaved() } fun loadItemForEditing(item: Item, garages: List) { + currentItem = item + 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 } @@ -140,9 +285,10 @@ class CreateItemViewModel : ViewModel() { selectedBoxName = box?.name imageUris.clear() + // Use .toUri() from core-ktx or standard Uri.parse imageUris.addAll(item.images.map { Uri.parse(it) }) - hasUnsavedChanges = false - lastSavedState = getCurrentStateHash() + markAsSaved() } + } \ 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 index e37e203..5e8248b 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/DataModels.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/DataModels.kt @@ -1,31 +1,51 @@ package com.samuel.inventorymanager.screens +import java.util.UUID + // --- Core Data Structures --- data class Item( - val id: String, + val id: String = UUID.randomUUID().toString(), val name: String, - val modelNumber: String?, - val description: String?, - val webLink: String?, - val condition: String, - val functionality: String, + val modelNumber: String? = null, + val description: String? = null, + val webLink: String? = null, + val condition: String = "Good", + val functionality: String = "Working", 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 + val boxId: String? = null, + val quantity: Int = 1, + val minPrice: Double? = null, + val maxPrice: Double? = null, + val weight: Double? = null, + val sizeCategory: String = "Medium", + val dimensions: String? = null, + val images: List = emptyList() +) + +data class Box( + val id: String = UUID.randomUUID().toString(), + val name: String +) + +data class Shelf( + val id: String = UUID.randomUUID().toString(), + val name: String, + val boxes: MutableList = mutableListOf() ) -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) +data class Cabinet( + val id: String = UUID.randomUUID().toString(), + val name: String, + val shelves: MutableList = mutableListOf() +) + +data class Garage( + val id: String = UUID.randomUUID().toString(), + val name: String, + val cabinets: MutableList = mutableListOf() +) // --- History Tracking --- sealed class HistoryAction { @@ -38,17 +58,17 @@ sealed class HistoryAction { } data class HistoryEntry( - val id: String, + val id: String = UUID.randomUUID().toString(), val itemId: String, val itemName: String, - val action: HistoryAction, + val actionType: String, 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 + val garages: List = emptyList(), + val items: List = emptyList(), + val history: List = emptyList() ) \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/GoogleSyncScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/GoogleSyncScreen.kt new file mode 100644 index 0000000..93f821b --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/GoogleSyncScreen.kt @@ -0,0 +1,890 @@ +@file:Suppress("DEPRECATION") + +package com.samuel.inventorymanager.screens + +import android.content.Context +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.Security +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.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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.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.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 com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.Scope +import com.google.api.client.extensions.android.http.AndroidHttp +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +private const val BACKUP_FILE_NAME = "inventory_manager_backup.json" +private const val AUTO_SAVE_INTERVAL = 30000L // 30 seconds + +@Composable +fun GoogleSyncScreen( + garages: List, + items: List, + history: List, + onDataRestored: (AppData) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var driveService by remember { mutableStateOf(null) } + var userEmail by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf("") } + var lastSyncTime by remember { mutableStateOf(null) } + var autoSaveEnabled by remember { mutableStateOf(true) } + var showDeleteDialog by remember { mutableStateOf(false) } + var showSignOutDialog by remember { mutableStateOf(false) } + + // Check if already signed in + LaunchedEffect(Unit) { + val account = GoogleSignIn.getLastSignedInAccount(context) + if (account != null) { + userEmail = account.email + val credential = GoogleAccountCredential.usingOAuth2( + context, + listOf(DriveScopes.DRIVE_APPDATA) + ).setSelectedAccount(account.account) + + driveService = Drive.Builder( + AndroidHttp.newCompatibleTransport(), + GsonFactory.getDefaultInstance(), + credential + ).setApplicationName("Inventory Manager").build() + } + } + + // Auto-save loop + LaunchedEffect(autoSaveEnabled, driveService, garages.size, items.size, history.size) { + if (autoSaveEnabled && driveService != null) { + while (true) { + delay(AUTO_SAVE_INTERVAL) + try { + val data = AppData(garages, items, history) + uploadToGoogleDrive(context, driveService!!, data) + lastSyncTime = System.currentTimeMillis() + statusMessage = "✅ Auto-saved" + } catch (e: Exception) { + Log.e("GoogleSync", "Auto-save failed", e) + } + } + } + } + + val signInLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.result + userEmail = account.email + + val credential = GoogleAccountCredential.usingOAuth2( + context, + listOf(DriveScopes.DRIVE_APPDATA) + ).setSelectedAccount(account.account) + + driveService = Drive.Builder( + AndroidHttp.newCompatibleTransport(), + GsonFactory.getDefaultInstance(), + credential + ).setApplicationName("Inventory Manager").build() + + statusMessage = "✅ Connected successfully" + } catch (e: Exception) { + Log.e("GoogleSync", "Sign-in failed", e) + statusMessage = "❌ Sign-in failed" + } + } + + val gso = remember { + GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(Scope(DriveScopes.DRIVE_APPDATA)) + .build() + } + val googleSignInClient = remember { GoogleSignIn.getClient(context, gso) } + + // Dialogs + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + icon = { Icon(Icons.Default.DeleteForever, null, tint = Color(0xFFEF4444)) }, + title = { Text("Delete All Cloud Data?", fontWeight = FontWeight.Bold) }, + text = { + Text("This will permanently delete ALL your inventory data from Google. Your local data will remain unchanged.\n\nThis cannot be undone!") + }, + confirmButton = { + Button( + onClick = { + scope.launch { + isLoading = true + try { + deleteFromGoogleDrive(driveService!!) + statusMessage = "🗑️ Cloud data deleted" + } catch (e: Exception) { + statusMessage = "❌ Delete failed" + } + isLoading = false + showDeleteDialog = false + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444)) + ) { + Text("Delete Everything") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel") + } + } + ) + } + + if (showSignOutDialog) { + AlertDialog( + onDismissRequest = { showSignOutDialog = false }, + icon = { Icon(Icons.Default.Logout, null) }, + title = { Text("Sign Out?", fontWeight = FontWeight.Bold) }, + text = { Text("Your data will remain in the cloud and locally. You can sign in again anytime.") }, + confirmButton = { + Button( + onClick = { + googleSignInClient.signOut() + driveService = null + userEmail = null + autoSaveEnabled = false + statusMessage = "👋 Signed out" + showSignOutDialog = false + } + ) { + Text("Sign Out") + } + }, + dismissButton = { + TextButton(onClick = { showSignOutDialog = false }) { + Text("Cancel") + } + } + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF0A0A0A)) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 20.dp) + ) { + // Header + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .background( + Brush.verticalGradient( + colors = listOf(Color(0xFF4285F4), Color(0xFF34A853)) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.CloudSync, + null, + modifier = Modifier.size(48.dp), + tint = Color.White + ) + Spacer(Modifier.height(12.dp)) + Text( + "Google Sync", + color = Color.White, + fontSize = 32.sp, + fontWeight = FontWeight.Bold + ) + Text( + "Automatic cloud backup", + color = Color.White.copy(alpha = 0.9f), + fontSize = 16.sp + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(Modifier.height(20.dp)) + + // Connection Status Card + AnimatedVisibility( + visible = driveService != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + ConnectedStatusCard( + userEmail = userEmail ?: "", + lastSyncTime = lastSyncTime, + autoSaveEnabled = autoSaveEnabled + ) + } + + AnimatedVisibility( + visible = driveService == null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + DisconnectedStatusCard() + } + + // Status Message + AnimatedVisibility( + visible = statusMessage.isNotEmpty(), + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically() + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B) + ), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Info, + null, + tint = Color(0xFF60A5FA), + modifier = Modifier.size(24.dp) + ) + Text( + statusMessage, + color = Color.White, + fontSize = 14.sp + ) + } + } + } + + // Main Actions + if (driveService == null) { + // Sign In Button + Button( + onClick = { signInLauncher.launch(googleSignInClient.signInIntent) }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF4285F4) + ), + shape = RoundedCornerShape(16.dp) + ) { + Icon(Icons.Default.Login, null) + Spacer(Modifier.width(12.dp)) + Text("Connect Google Account", fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + } else { + // Auto-save Toggle + SyncOptionCard( + icon = if (autoSaveEnabled) Icons.Default.CloudDone else Icons.Default.CloudOff, + title = "Auto-Save", + description = if (autoSaveEnabled) "Saves every 30 seconds" else "Disabled", + enabled = autoSaveEnabled, + onToggle = { autoSaveEnabled = !autoSaveEnabled } + ) + + // Manual Actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ActionButton( + icon = Icons.Default.CloudUpload, + text = "Backup Now", + color = Color(0xFF10B981), + modifier = Modifier.weight(1f), + enabled = !isLoading + ) { + scope.launch { + isLoading = true + try { + val data = AppData(garages, items, history) + uploadToGoogleDrive(context, driveService!!, data) + lastSyncTime = System.currentTimeMillis() + statusMessage = "✅ Backup complete" + } catch (e: Exception) { + statusMessage = "❌ Backup failed" + } + isLoading = false + } + } + + ActionButton( + icon = Icons.Default.CloudDownload, + text = "Restore", + color = Color(0xFF8B5CF6), + modifier = Modifier.weight(1f), + enabled = !isLoading + ) { + scope.launch { + isLoading = true + try { + val data = downloadFromGoogleDrive(driveService!!) + if (data != null) { + onDataRestored(data) + statusMessage = "✅ Data restored" + } else { + statusMessage = "⚠️ No backup found" + } + } catch (e: Exception) { + statusMessage = "❌ Restore failed" + } + isLoading = false + } + } + } + + Spacer(Modifier.height(8.dp)) + + // Danger Zone + DangerZoneCard( + onSignOut = { showSignOutDialog = true }, + onDeleteAll = { showDeleteDialog = true } + ) + } + + // Info Cards + InfoSection() + } + } + + // Loading Overlay + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = Color(0xFF4285F4), + modifier = Modifier.size(64.dp) + ) + } + } + } +} + +@Composable +private fun ConnectedStatusCard( + userEmail: String, + lastSyncTime: Long?, + autoSaveEnabled: Boolean +) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + Card( + colors = CardDefaults.cardColors( + containerColor = Color(0xFF10B981).copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0xFF10B981).copy(alpha = if (autoSaveEnabled) alpha else 1f)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.CheckCircle, + null, + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + "Connected", + color = Color(0xFF10B981), + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Text( + userEmail, + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp + ) + if (lastSyncTime != null) { + val timeAgo = getTimeAgo(lastSyncTime) + Text( + "Last sync: $timeAgo", + color = Color.White.copy(alpha = 0.5f), + fontSize = 12.sp + ) + } + } + } + } +} + +@Composable +private fun DisconnectedStatusCard() { + Card( + colors = CardDefaults.cardColors( + containerColor = Color(0xFFFBBF24).copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0xFFFBBF24)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.CloudOff, + null, + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + Column { + Text( + "Not Connected", + color = Color(0xFFFBBF24), + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Text( + "Sign in to enable cloud backup", + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp + ) + } + } + } +} + +@Composable +private fun SyncOptionCard( + icon: ImageVector, + title: String, + description: String, + enabled: Boolean, + onToggle: () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B) + ), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + null, + tint = if (enabled) Color(0xFF10B981) else Color.White.copy(alpha = 0.5f), + modifier = Modifier.size(32.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + title, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + Text( + description, + color = Color.White.copy(alpha = 0.6f), + fontSize = 13.sp + ) + } + Switch( + checked = enabled, + onCheckedChange = { onToggle() }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Color(0xFF10B981) + ) + ) + } + } +} + +@Composable +private fun ActionButton( + icon: ImageVector, + text: String, + color: Color, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit +) { + Button( + onClick = onClick, + modifier = modifier.height(80.dp), + colors = ButtonDefaults.buttonColors( + containerColor = color.copy(alpha = 0.15f), + disabledContainerColor = Color.Gray.copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(16.dp), + enabled = enabled + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + icon, + null, + tint = if (enabled) color else Color.Gray, + modifier = Modifier.size(28.dp) + ) + Text( + text, + color = if (enabled) Color.White else Color.Gray, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun DangerZoneCard( + onSignOut: () -> Unit, + onDeleteAll: () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = Color(0xFFEF4444).copy(alpha = 0.1f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "⚠️ Danger Zone", + color = Color(0xFFEF4444), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + + OutlinedButton( + onClick = onSignOut, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Logout, null) + Spacer(Modifier.width(8.dp)) + Text("Sign Out") + } + + OutlinedButton( + onClick = onDeleteAll, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color(0xFFEF4444) + ), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.DeleteForever, null) + Spacer(Modifier.width(8.dp)) + Text("Delete All Cloud Data") + } + } + } +} + +@Composable +private fun InfoSection() { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "How it works", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + + InfoCard( + icon = Icons.Default.Security, + title = "Secure & Private", + description = "Your data is encrypted and stored in your Google account" + ) + + InfoCard( + icon = Icons.Default.Sync, + title = "Automatic Backup", + description = "Auto-saves every 30 seconds when enabled" + ) + + InfoCard( + icon = Icons.Default.Devices, + title = "Access Anywhere", + description = "Restore your data on any device with your Google account" + ) + } +} + +@Composable +private fun InfoCard(icon: ImageVector, title: String, description: String) { + Card( + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + icon, + null, + tint = Color(0xFF60A5FA), + modifier = Modifier.size(24.dp) + ) + Column { + Text( + title, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Text( + description, + color = Color.White.copy(alpha = 0.6f), + fontSize = 12.sp + ) + } + } + } +} + +// Helper functions +private fun getTimeAgo(timestamp: Long): String { + val diff = System.currentTimeMillis() - timestamp + val seconds = diff / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + + return when { + seconds < 60 -> "just now" + minutes < 60 -> "$minutes min ago" + hours < 24 -> "$hours hr ago" + else -> "${hours / 24} days ago" + } +} + +private suspend fun uploadToGoogleDrive(context: Context, driveService: Drive, data: AppData) { + withContext(Dispatchers.IO) { + try { + val json = Gson().toJson(data) + val content = ByteArrayContent("application/json", json.toByteArray()) + + // Check if file exists + val result = driveService.files().list() + .setSpaces("appDataFolder") + .setQ("name='$BACKUP_FILE_NAME'") + .execute() + + if (result.files.isNullOrEmpty()) { + // Create new file + val fileMetadata = com.google.api.services.drive.model.File().apply { + name = BACKUP_FILE_NAME + parents = listOf("appDataFolder") + } + driveService.files().create(fileMetadata, content).execute() + } else { + // Update existing file + val fileId = result.files[0].id + driveService.files().update(fileId, null, content).execute() + } + + Log.d("GoogleSync", "Upload successful") + } catch (e: Exception) { + Log.e("GoogleSync", "Upload failed", e) + throw e + } + } +} + +private suspend fun downloadFromGoogleDrive(driveService: Drive): AppData? { + return withContext(Dispatchers.IO) { + try { + val result = driveService.files().list() + .setSpaces("appDataFolder") + .setQ("name='$BACKUP_FILE_NAME'") + .execute() + + if (result.files.isNullOrEmpty()) { + return@withContext null + } + + val fileId = result.files[0].id + val outputStream = ByteArrayOutputStream() + driveService.files().get(fileId).executeMediaAndDownloadTo(outputStream) + + val json = String(outputStream.toByteArray()) + Gson().fromJson(json, AppData::class.java) + } catch (e: Exception) { + Log.e("GoogleSync", "Download failed", e) + null + } + } +} + +private suspend fun deleteFromGoogleDrive(driveService: Drive) { + withContext(Dispatchers.IO) { + try { + val result = driveService.files().list() + .setSpaces("appDataFolder") + .setQ("name='$BACKUP_FILE_NAME'") + .execute() + + if (!result.files.isNullOrEmpty()) { + val fileId = result.files[0].id + driveService.files().delete(fileId).execute() + Log.d("GoogleSync", "Delete successful") + } + } catch (e: Exception) { + Log.e("GoogleSync", "Delete failed", e) + throw e + } + } +} \ 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 index 948aed0..b359c7d 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/HelpScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/HelpScreen.kt @@ -1,7 +1,6 @@ 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 @@ -29,7 +28,7 @@ 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.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -47,6 +46,7 @@ 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 +import androidx.core.net.toUri @Composable fun HelpScreen() { @@ -60,7 +60,9 @@ fun HelpScreen() { item { // --- HEADER --- Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text("❓", fontSize = 56.sp) @@ -132,7 +134,7 @@ fun HelpScreen() { // --- DATA & BACKUP --- item { CollapsibleHelpSection( - title = "Data, Backup & Sync", + title = "Data, Backup & (Sync COMING SOON...)", icon = Icons.Default.SdStorage ) { HelpContentBlock( @@ -181,11 +183,13 @@ fun HelpScreen() { 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") + // FIX: Renamed InfoRow to AppInfoRow to solve overload ambiguity + AppInfoRow("Version:", "1.0 (FIRST)") + AppInfoRow("Platform:", "Android (Jetpack Compose)") + AppInfoRow("Data Storage:", "Local Device Storage") - Divider(modifier = Modifier.padding(vertical = 8.dp)) + // FIX: Replaced deprecated Divider with HorizontalDivider + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) // Contact Section ContactRow( @@ -193,7 +197,8 @@ fun HelpScreen() { title = "GitHub of Parminder", subtitle = "github.com/JohnJackson12", onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/JohnJackson12")) + // FIX: Use .toUri() KTX extension + val intent = Intent(Intent.ACTION_VIEW, "https://github.com/JohnJackson12".toUri()) context.startActivity(intent) } ) @@ -203,7 +208,8 @@ fun HelpScreen() { title = "GitHub of Samuel", subtitle = "github.com/SamS34", onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/SamS34")) + // FIX: Use .toUri() KTX extension + val intent = Intent(Intent.ACTION_VIEW, "https://github.com/SamS34".toUri()) context.startActivity(intent) } ) @@ -214,7 +220,8 @@ fun HelpScreen() { subtitle = "parminder.nz@gmail.com", onClick = { val intent = Intent(Intent.ACTION_SENDTO).apply { - data = Uri.parse("mailto:parminder.nz@gmail.com") + // FIX: Use .toUri() KTX extension + data = "mailto:parminder.nz@gmail.com".toUri() } context.startActivity(intent) } @@ -226,7 +233,8 @@ fun HelpScreen() { subtitle = "sam.of.s34@gmail.com", onClick = { val intent = Intent(Intent.ACTION_SENDTO).apply { - data = Uri.parse("mailto:sam.of.s34@gmail.com") + // FIX: Use .toUri() KTX extension + data = "mailto:sam.of.s34@gmail.com".toUri() } context.startActivity(intent) } @@ -239,7 +247,7 @@ fun HelpScreen() { // ====================================================================== -// HELPER COMPOSABLES +// Helper Composables // These are the reusable building blocks that make the screen work! ✨ // ====================================================================== @@ -291,7 +299,8 @@ private fun CollapsibleHelpSection( .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Divider() + // FIX: Replaced deprecated Divider with HorizontalDivider + HorizontalDivider() Spacer(Modifier.height(4.dp)) content() } @@ -332,7 +341,9 @@ private fun ContactRow( onClick: () -> Unit ) { Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), verticalAlignment = Alignment.CenterVertically ) { Icon(icon, null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) @@ -352,9 +363,10 @@ private fun ContactRow( /** * Displays a simple "Label: Value" row for app information. + * FIX: Renamed from InfoRow to AppInfoRow to resolve compiler ambiguity. */ @Composable -private fun InfoRow(label: String, value: String) { +private fun AppInfoRow(label: String, value: String) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text( text = label, diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/HistoryScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/HistoryScreen.kt index 27bb1fd..117cfa1 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/HistoryScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/HistoryScreen.kt @@ -1,5 +1,6 @@ package com.samuel.inventorymanager.screens +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,194 +10,342 @@ 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.Add +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.AddCircle -import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward 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.History 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.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable 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.draw.alpha 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.dp +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale + + +sealed class ActionType( + val key: String, + val label: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val color: Color +) { + object Added : ActionType("Added", "Added", Icons.Filled.AddCircle, Color(0xFF10B981)) + object Updated : ActionType("Updated", "Updated", Icons.Filled.Edit, Color(0xFFF59E0B)) + object Removed : ActionType("Removed", "Removed", Icons.Filled.Delete, Color(0xFFEF4444)) + object QuantityChanged : ActionType("QuantityChanged", "Qty Changed", Icons.Filled.SwapVert, Color(0xFF8B5CF6)) + object CheckedOut : ActionType("CheckedOut", "Checked Out", Icons.AutoMirrored.Filled.Logout, Color(0xFFEF4444)) + object CheckedIn : ActionType("CheckedIn", "Checked In", Icons.AutoMirrored.Filled.Login, Color(0xFF10B981)) + data class Unknown(val raw: String) : ActionType(raw, if (raw.isBlank()) "Unknown" else raw, Icons.Filled.History, Color(0xFF6B7280)) + + companion object { + fun fromRaw(raw: String): ActionType = when (raw) { + Added.key -> Added + Updated.key -> Updated + Removed.key -> Removed + QuantityChanged.key -> QuantityChanged + CheckedOut.key -> CheckedOut + CheckedIn.key -> CheckedIn + else -> Unknown(raw) + } + + val filterableTypes = listOf(Added, Updated, Removed, QuantityChanged, CheckedOut, CheckedIn) + } +} + +enum class HistorySortField { DATE, NAME } +enum class HistorySortDirection { ASCENDING, DESCENDING } +data class HistorySort(val field: HistorySortField, val direction: HistorySortDirection) + +data class HistoryUiState( + val searchQuery: String = "", + val selectedActionKey: String? = null, + val sortField: HistorySortField = HistorySortField.DATE, + val sortDirection: HistorySortDirection = HistorySortDirection.DESCENDING, + val pendingDeleteId: String? = null +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun HistoryScreen( history: List, items: List, onItemClick: (Item) -> Unit, - onClearHistory: () -> Unit + onClearHistory: () -> Unit, + onDeleteHistoryEntry: (HistoryEntry) -> 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 localHistory = remember(history) { mutableStateListOf().apply { addAll(history) } } + var uiState by remember { mutableStateOf(HistoryUiState()) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() - val matchesSearch = searchQuery.isBlank() || - entry.itemName.contains(searchQuery, ignoreCase = true) || - entry.description.contains(searchQuery, ignoreCase = true) + val selectedActionType = uiState.selectedActionKey?.let { ActionType.fromRaw(it) } + val filteredSorted = remember(localHistory, uiState) { + localHistory.filter { e -> + (selectedActionType == null || ActionType.fromRaw(e.actionType).key == selectedActionType.key) && + (uiState.searchQuery.isBlank() || e.itemName.contains(uiState.searchQuery, ignoreCase = true) || e.description.contains(uiState.searchQuery, ignoreCase = true)) + }.sortedWith { a, b -> + val cmp = when (uiState.sortField) { + HistorySortField.DATE -> a.timestamp.compareTo(b.timestamp) + HistorySortField.NAME -> a.itemName.lowercase().compareTo(b.itemName.lowercase()) + } + if (uiState.sortDirection == HistorySortDirection.ASCENDING) cmp else -cmp + } + } - matchesAction && matchesSearch + fun clearAllWithUndo() { + val backup = localHistory.toList() + localHistory.clear() + scope.launch { + val result = snackbarHostState.showSnackbar("History cleared", actionLabel = "Undo", duration = SnackbarDuration.Short) + if (result == SnackbarResult.ActionPerformed) localHistory.addAll(backup) else onClearHistory() } } - Column(modifier = Modifier.fillMaxSize()) { - HistoryHeader( - searchQuery = searchQuery, - onSearchQueryChange = { searchQuery = it }, - selectedActionType = selectedActionType, - onActionSelect = { actionType -> - selectedActionType = if (selectedActionType == actionType) null else actionType - }, - onClearHistory = onClearHistory - ) + fun deleteWithUndo(entry: HistoryEntry) { + localHistory.remove(entry) + onDeleteHistoryEntry(entry) + scope.launch { + val result = snackbarHostState.showSnackbar("Entry deleted", actionLabel = "Undo", duration = SnackbarDuration.Short) + if (result == SnackbarResult.ActionPerformed) localHistory.add(entry) + } + } - if (filteredHistory.isEmpty()) { - EmptyHistoryState() - } else { - LazyColumn( + Scaffold(topBar = { HistoryTopBar() }, snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> + Column( + Modifier.padding(padding).fillMaxSize() + ) { + HistoryHeader( + searchQuery = uiState.searchQuery, + selectedActionType = selectedActionType, + sort = HistorySort(uiState.sortField, uiState.sortDirection), + historyCount = filteredSorted.size, + totalCount = localHistory.size, + onSearchQueryChange = { uiState = uiState.copy(searchQuery = it) }, + onActionSelect = { type -> uiState = uiState.copy(selectedActionKey = if (uiState.selectedActionKey == type.key) null else type.key) }, + onSortFieldChange = { uiState = uiState.copy(sortField = it) }, + onSortDirectionToggle = { + val newDir = if (uiState.sortDirection == HistorySortDirection.DESCENDING) HistorySortDirection.ASCENDING else HistorySortDirection.DESCENDING + uiState = uiState.copy(sortDirection = newDir) + }, + onClearHistoryClick = { if (localHistory.isNotEmpty()) clearAllWithUndo() } + ) + Spacer(Modifier.height(12.dp)) + if (filteredSorted.isEmpty()) EmptyHistoryState(Modifier.fillMaxSize().padding(24.dp)) else LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp) ) { - items(filteredHistory, key = { it.id }) { entry -> + items(filteredSorted, key = { it.id }) { entry -> HistoryItemCard( entry = entry, - onClick = { - items.find { it.id == entry.itemId }?.let { item -> - onItemClick(item) - } - } + modifier = Modifier.fillMaxWidth(), + onClick = { items.find { it.id == entry.itemId }?.let(onItemClick) }, + onDeleteClick = { deleteWithUndo(entry) } ) } } } } + + uiState.pendingDeleteId?.let { id -> + localHistory.find { it.id == id }?.let { entry -> + ConfirmDeleteDialog( + entry = entry, + onDismiss = { uiState = uiState.copy(pendingDeleteId = null) }, + onConfirm = { + uiState = uiState.copy(pendingDeleteId = null) + deleteWithUndo(entry) + } + ) + } + } } -// Helper enum to simplify filtering -enum class ActionType { - ADDED, UPDATED, REMOVED, QUANTITY_CHANGED, CHECKED_OUT, CHECKED_IN +@Composable +private fun HistoryTopBar() { + Surface(tonalElevation = 2.dp, shadowElevation = 2.dp) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.History, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(26.dp)) + Spacer(Modifier.width(12.dp)) + Column { + Text("History", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text("Track changes to your items", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + // Remove or add meaningful menu here if desired. + } + } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun HistoryHeader( searchQuery: String, - onSearchQueryChange: (String) -> Unit, selectedActionType: ActionType?, + sort: HistorySort, + historyCount: Int, + totalCount: Int, + onSearchQueryChange: (String) -> Unit, onActionSelect: (ActionType) -> Unit, - onClearHistory: () -> Unit + onSortFieldChange: (HistorySortField) -> Unit, + onSortDirectionToggle: () -> Unit, + onClearHistoryClick: () -> Unit, ) { Surface( - modifier = Modifier.fillMaxWidth(), - shadowElevation = 4.dp + Modifier.fillMaxWidth(), + tonalElevation = 4.dp, + shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { + Column(Modifier.padding(16.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "$historyCount item${if (historyCount == 1) "" else "s"} shown" + if (historyCount != totalCount) ", total $totalCount" else "", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + ) + TextButton( + onClick = onClearHistoryClick, + enabled = totalCount > 0, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + ) { + Icon(Icons.Filled.DeleteForever, null) + Spacer(Modifier.width(4.dp)) + Text("Clear All") + } + } + Spacer(Modifier.height(12.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) + placeholder = { Text("Search item or description...") }, + singleLine = true, + leadingIcon = { Icon(Icons.Filled.Search, null) }, + shape = RoundedCornerShape(12.dp), ) - + Spacer(Modifier.height(12.dp)) + Text("Filter by action", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, 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)) - } - ) + ActionType.filterableTypes.forEach { type -> 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)) - } + selected = selectedActionType?.key == type.key, + onClick = { onActionSelect(type) }, + label = { Text(type.label) }, + leadingIcon = { Icon(type.icon, null) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = type.color.copy(alpha = 0.2f), + selectedLabelColor = type.color, + selectedLeadingIconColor = type.color, + ), ) } - IconButton(onClick = onClearHistory) { + } + Spacer(Modifier.height(14.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text("Sort by:", style = MaterialTheme.typography.labelMedium) + var expanded by remember { mutableStateOf(false) } + Box { + TextButton( + onClick = { expanded = true }, + modifier = Modifier.height(36.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp) + ) { + Text( + when (sort.field) { + HistorySortField.DATE -> "Date" + HistorySortField.NAME -> "Name" + }, + modifier = Modifier.padding(end = 4.dp) + ) + Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort icon", modifier = Modifier.size(16.dp)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(text = { Text("Date") }, onClick = { expanded = false; onSortFieldChange(HistorySortField.DATE) }) + DropdownMenuItem(text = { Text("Name") }, onClick = { expanded = false; onSortFieldChange(HistorySortField.NAME) }) + } + } + IconButton( + onClick = onSortDirectionToggle, + modifier = Modifier.size(36.dp) + ) { Icon( - Icons.Default.DeleteForever, - "Clear History", - tint = MaterialTheme.colorScheme.error + imageVector = if (sort.direction == HistorySortDirection.DESCENDING) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, + contentDescription = "Toggle sort direction" ) } } @@ -205,146 +354,120 @@ private fun HistoryHeader( } @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) - ) - } - } - +private fun HistoryItemCard( + entry: HistoryEntry, + onClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, + elevation: Dp = 2.dp, +) { + val details = remember(entry.actionType) { detailsFor(entry) } + var menuExpanded by remember { mutableStateOf(false) } Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - elevation = CardDefaults.cardElevation(2.dp) + modifier = modifier.clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(elevation), + shape = RoundedCornerShape(14.dp), ) { Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically + Modifier.padding(horizontal = 14.dp, vertical = 12.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 - ) + Box( + Modifier.size(44.dp).background(details.color.copy(alpha = 0.16f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(details.icon, details.label, tint = details.color, modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(entry.itemName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(formatTimestamp(entry.timestamp), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(details.label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = details.color) + Text("•", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.alpha(0.7f)) + Text("ID: ${entry.id.take(8)}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (entry.description.isNotBlank()) { + Text(entry.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Filled.MoreVert, "More actions") + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem(text = { Text("View item") }, onClick = { menuExpanded = false; onClick() }) + DropdownMenuItem( + text = { Text("Delete entry") }, + onClick = { menuExpanded = false; onDeleteClick() }, + leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.error) } + ) + } } - - 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 EmptyHistoryState(modifier: Modifier = Modifier) { + Box(modifier, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp)) { + Box( + Modifier.size(88.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.HistoryToggleOff, "No history", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(52.dp).alpha(0.9f)) + } + Text("No history yet", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text("Changes you make to items, quantities, and check-in/out will appear here.", style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } +@Composable +private fun ConfirmDeleteDialog( + entry: HistoryEntry, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.error) }, + title = { Text("Delete this entry?") }, + text = { Text("Remove the history record for “${entry.itemName}”? The item itself stays in inventory.") }, + confirmButton = { + TextButton(onClick = onConfirm) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +private fun detailsFor(entry: HistoryEntry): ActionDetails { + val type = ActionType.fromRaw(entry.actionType) + return ActionDetails(type.icon, type.label, type.color) +} + +private data class ActionDetails(val icon: ImageVector, val label: String, val color: Color) + 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)) - } + diff < 60_000L -> "Just now" + diff < 3_600_000L -> "${diff / 60_000L}m ago" + diff < 86_400_000L -> "${diff / 3_600_000L}h ago" + diff < 7L * 86_400_000L -> "${diff / 86_400_000L}d ago" + else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(Date(timestamp)) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/ImageEditScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/ImageEditScreen.kt new file mode 100644 index 0000000..c44a651 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/ImageEditScreen.kt @@ -0,0 +1,843 @@ +package com.samuel.inventorymanager.screens + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Matrix +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +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.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CropFree +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.RotateRight +import androidx.compose.material.icons.filled.Tune +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.zIndex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +enum class ProcessingChoice { MANUAL, AI_OCR } +enum class EditTool { CROP, ROTATE, ADJUST } + +@Composable +fun ImageEditScreen( + imageUri: Uri, + onNext: (Bitmap, ProcessingChoice) -> Unit, + onCancel: () -> Unit +) { + val context = LocalContext.current + + var bitmap by remember { mutableStateOf(null) } + var rotation by remember { mutableFloatStateOf(0f) } + var brightness by remember { mutableFloatStateOf(0f) } + var contrast by remember { mutableFloatStateOf(1f) } + var currentTool by remember { mutableStateOf(EditTool.CROP) } + var showChoiceDialog by remember { mutableStateOf(false) } + + // Load bitmap once and keep it + LaunchedEffect(imageUri) { + withContext(Dispatchers.IO) { + bitmap = try { + context.contentResolver.openInputStream(imageUri)?.use { + BitmapFactory.decodeStream(it) + } + } catch (e: Exception) { + null + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + bitmap?.let { bmp -> + Column( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF0F172A), + Color(0xFF1E293B) + ) + ) + ) + ) { + // Top Bar + ModernTopBar( + onCancel = onCancel, + onNext = { showChoiceDialog = true } + ) + + // Image Preview + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(16.dp) + .clip(RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + EditableImagePreview( + bitmap = bmp, + rotation = rotation, + brightness = brightness, + contrast = contrast, + currentTool = currentTool + ) + } + + // Tools Panel + ModernToolsPanel( + currentTool = currentTool, + onToolChange = { currentTool = it }, + rotation = rotation, + onRotationChange = { rotation = it }, + brightness = brightness, + onBrightnessChange = { brightness = it }, + contrast = contrast, + onContrastChange = { contrast = it } + ) + } + } ?: LoadingScreen() + + // Choice Dialog + if (showChoiceDialog) { + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(100f) + .background(Color.Black.copy(alpha = 0.5f)) + ) { + ProcessingChoiceDialog( + onDismiss = { showChoiceDialog = false }, + onChoice = { choice -> + bitmap?.let { bmp -> + val processedBitmap = applyAllEdits(bmp, rotation, brightness, contrast) + onNext(processedBitmap, choice) + } + showChoiceDialog = false + } + ) + } + } + } +} + +@Composable +fun ModernTopBar(onCancel: () -> Unit, onNext: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .zIndex(10f), + color = Color(0xFF1E293B).copy(alpha = 0.95f), + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onCancel, + modifier = Modifier + .size(48.dp) + .background(Color.White.copy(alpha = 0.1f), CircleShape) + ) { + Icon( + Icons.Default.Close, + null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + + Text( + "✨ Edit Photo", + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + + Button( + onClick = onNext, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF8B5CF6) + ), + shape = RoundedCornerShape(24.dp), + modifier = Modifier.height(48.dp) + ) { + Text("Next", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.width(8.dp)) + Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(20.dp)) + } + } + } +} + +@Composable +fun EditableImagePreview( + bitmap: Bitmap, + rotation: Float, + brightness: Float, + contrast: Float, + currentTool: EditTool +) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + val filteredBitmap by remember { + derivedStateOf { + applyImageFilters(bitmap, brightness, contrast).asImageBitmap() + } + } + + Card( + modifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = Color.Black.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize(0.9f) + .clip(RoundedCornerShape(16.dp)) + ) { + Image( + bitmap = filteredBitmap, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + rotationZ = rotation, + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 3f) + val maxOffset = 500f + offset = Offset( + (offset.x + pan.x).coerceIn(-maxOffset, maxOffset), + (offset.y + pan.y).coerceIn(-maxOffset, maxOffset) + ) + } + }, + contentScale = ContentScale.Fit + ) + } + + if (currentTool == EditTool.CROP) { + Box( + modifier = Modifier.fillMaxSize(0.85f), + contentAlignment = Alignment.Center + ) { + CropGridOverlay() + } + } + } + } +} + +@Composable +fun CropGridOverlay() { + Canvas(modifier = Modifier.fillMaxSize()) { + val gridColor = Color.White.copy(alpha = 0.8f) + val strokeWidth = 3f + + drawRect( + color = gridColor, + size = size, + style = androidx.compose.ui.graphics.drawscope.Stroke(width = strokeWidth) + ) + + for (i in 1..2) { + val x = size.width * i / 3 + drawLine( + color = gridColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = strokeWidth + ) + } + + for (i in 1..2) { + val y = size.height * i / 3 + drawLine( + color = gridColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = strokeWidth + ) + } + + val handleSize = 40f + val cornerColor = Color(0xFF8B5CF6) + + drawLine(cornerColor, Offset(0f, 0f), Offset(handleSize, 0f), strokeWidth * 2) + drawLine(cornerColor, Offset(0f, 0f), Offset(0f, handleSize), strokeWidth * 2) + + drawLine(cornerColor, Offset(size.width - handleSize, 0f), Offset(size.width, 0f), strokeWidth * 2) + drawLine(cornerColor, Offset(size.width, 0f), Offset(size.width, handleSize), strokeWidth * 2) + + drawLine(cornerColor, Offset(0f, size.height - handleSize), Offset(0f, size.height), strokeWidth * 2) + drawLine(cornerColor, Offset(0f, size.height), Offset(handleSize, size.height), strokeWidth * 2) + + drawLine(cornerColor, Offset(size.width, size.height - handleSize), Offset(size.width, size.height), strokeWidth * 2) + drawLine(cornerColor, Offset(size.width - handleSize, size.height), Offset(size.width, size.height), strokeWidth * 2) + } +} + +@Composable +fun ModernToolsPanel( + currentTool: EditTool, + onToolChange: (EditTool) -> Unit, + rotation: Float, + onRotationChange: (Float) -> Unit, + brightness: Float, + onBrightnessChange: (Float) -> Unit, + contrast: Float, + onContrastChange: (Float) -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .zIndex(10f), + color = Color(0xFF1E293B).copy(alpha = 0.95f), + shadowElevation = 16.dp + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + EditTool.entries.forEach { tool -> + ModernToolButton( + tool = tool, + isSelected = currentTool == tool, + onClick = { onToolChange(tool) } + ) + } + } + + AnimatedContent( + targetState = currentTool, + label = "tool_controls", + transitionSpec = { + fadeIn() + slideInVertically() togetherWith fadeOut() + slideOutVertically() + } + ) { tool -> + when (tool) { + EditTool.CROP -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF8B5CF6).copy(alpha = 0.1f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Info, + null, + tint = Color(0xFF8B5CF6), + modifier = Modifier.size(24.dp) + ) + Text( + "Pinch to zoom • Drag to reposition", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + EditTool.ROTATE -> { + ModernSliderControl( + label = "Rotation", + value = rotation, + onValueChange = onRotationChange, + valueRange = -180f..180f, + displayValue = "${rotation.toInt()}°", + color = Color(0xFF10B981) + ) + } + EditTool.ADJUST -> { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ModernSliderControl( + label = "Brightness", + value = brightness, + onValueChange = onBrightnessChange, + valueRange = -1f..1f, + displayValue = "${(brightness * 100).toInt()}", + color = Color(0xFFFBBF24) + ) + ModernSliderControl( + label = "Contrast", + value = contrast, + onValueChange = onContrastChange, + valueRange = 0f..2f, + displayValue = "${(contrast * 100).toInt()}%", + color = Color(0xFFEC4899) + ) + } + } + } + } + } + } +} + +@Composable +fun ModernToolButton(tool: EditTool, isSelected: Boolean, onClick: () -> Unit) { + val (icon, label) = when (tool) { + EditTool.CROP -> Icons.Default.CropFree to "Crop" + EditTool.ROTATE -> Icons.Default.RotateRight to "Rotate" + EditTool.ADJUST -> Icons.Default.Tune to "Adjust" + } + + val backgroundColor by animateColorAsState( + targetValue = if (isSelected) Color(0xFF8B5CF6) else Color(0xFF334155), + label = "bg_color" + ) + + val scale by animateFloatAsState( + targetValue = if (isSelected) 1.05f else 1f, + label = "scale" + ) + + Surface( + onClick = onClick, + shape = RoundedCornerShape(20.dp), + color = backgroundColor, + modifier = Modifier + .graphicsLayer(scaleX = scale, scaleY = scale) + .padding(4.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 28.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + icon, + null, + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + Text( + label, + color = Color.White, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ) + } + } +} + +@Composable +fun ModernSliderControl( + label: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + displayValue: String, + color: Color +) { + var showEditDialog by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + label, + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + Surface( + onClick = { showEditDialog = true }, + shape = RoundedCornerShape(12.dp), + color = color.copy(alpha = 0.2f) + ) { + Text( + displayValue, + color = color, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + colors = SliderDefaults.colors( + thumbColor = color, + activeTrackColor = color, + inactiveTrackColor = color.copy(alpha = 0.3f) + ), + modifier = Modifier.height(48.dp) + ) + } + + if (showEditDialog) { + ValueEditDialog( + title = label, + currentValue = value, + valueRange = valueRange, + onDismiss = { showEditDialog = false }, + onConfirm = { newValue -> + onValueChange(newValue) + showEditDialog = false + }, + color = color + ) + } +} + +@Composable +fun ValueEditDialog( + title: String, + currentValue: Float, + valueRange: ClosedFloatingPointRange, + onDismiss: () -> Unit, + onConfirm: (Float) -> Unit, + color: Color +) { + var textValue by remember { + mutableStateOf( + when (title) { + "Rotation" -> currentValue.toInt().toString() + "Brightness" -> (currentValue * 100).toInt().toString() + "Contrast" -> (currentValue * 100).toInt().toString() + else -> currentValue.toString() + } + ) + } + + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B) + ) + ) { + Column( + modifier = Modifier.padding(28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + "Set $title", + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + OutlinedTextField( + value = textValue, + onValueChange = { textValue = it }, + label = { Text(title) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedLabelColor = color, + unfocusedLabelColor = Color.White.copy(alpha = 0.6f), + cursorColor = color, + focusedIndicatorColor = color, + unfocusedIndicatorColor = Color.White.copy(alpha = 0.3f) + ), + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White.copy(alpha = 0.1f) + ), + modifier = Modifier.weight(1f) + ) { + Text("Cancel", color = Color.White) + } + + Button( + onClick = { + val parsedValue = textValue.toFloatOrNull() + if (parsedValue != null) { + val finalValue = when (title) { + "Rotation" -> parsedValue.coerceIn(valueRange) + "Brightness" -> (parsedValue / 100f).coerceIn(valueRange) + "Contrast" -> (parsedValue / 100f).coerceIn(valueRange) + else -> parsedValue.coerceIn(valueRange) + } + onConfirm(finalValue) + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = color + ), + modifier = Modifier.weight(1f) + ) { + Text("Apply", fontWeight = FontWeight.Bold) + } + } + } + } + } +} + +@Composable +fun ProcessingChoiceDialog( + onDismiss: () -> Unit, + onChoice: (ProcessingChoice) -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B) + ) + ) { + Column( + modifier = Modifier.padding(28.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.AutoAwesome, + null, + tint = Color(0xFF8B5CF6), + modifier = Modifier.size(48.dp) + ) + Text( + "How would you like to proceed?", + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + } + + ChoiceOptionCard( + icon = Icons.Default.Psychology, + title = "✨ AI Auto-Fill", + description = "Let AI extract all item details automatically (FREE)", + color = Color(0xFF8B5CF6), + onClick = { onChoice(ProcessingChoice.AI_OCR) } + ) + + ChoiceOptionCard( + icon = Icons.Default.Edit, + title = "✍️ Manual Entry", + description = "Fill in item details yourself", + color = Color(0xFF10B981), + onClick = { onChoice(ProcessingChoice.MANUAL) } + ) + } + } + } +} + +@Composable +fun ChoiceOptionCard( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + description: String, + color: Color, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.15f) + ), + border = BorderStroke(2.dp, color.copy(alpha = 0.3f)) + ) { + Row( + modifier = Modifier.padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = CircleShape, + color = color, + modifier = Modifier.size(56.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + icon, + null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + title, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Bold + ) + Text( + description, + color = Color.White.copy(alpha = 0.7f), + fontSize = 13.sp + ) + } + } + } +} + +@Composable +fun LoadingScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF0F172A)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = Color(0xFF8B5CF6), + modifier = Modifier.size(64.dp) + ) + } +} + +// Helper Functions +fun applyImageFilters(bitmap: Bitmap, brightness: Float, contrast: Float): Bitmap { + val colorMatrix = ColorMatrix().apply { + set(floatArrayOf( + contrast, 0f, 0f, 0f, brightness * 255, + 0f, contrast, 0f, 0f, brightness * 255, + 0f, 0f, contrast, 0f, brightness * 255, + 0f, 0f, 0f, 1f, 0f + )) + } + val paint = android.graphics.Paint().apply { + colorFilter = ColorMatrixColorFilter(colorMatrix) + } + val result = Bitmap.createBitmap(bitmap.width, bitmap.height, bitmap.config ?: Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(result) + canvas.drawBitmap(bitmap, 0f, 0f, paint) + return result +} + +fun applyAllEdits(bitmap: Bitmap, rotation: Float, brightness: Float, contrast: Float): Bitmap { + var result = applyImageFilters(bitmap, brightness, contrast) + + if (rotation != 0f) { + val matrix = Matrix().apply { postRotate(rotation) } + result = Bitmap.createBitmap(result, 0, 0, result.width, result.height, matrix, true) + } + + return result +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/ImageProcessingScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/ImageProcessingScreen.kt new file mode 100644 index 0000000..1f0146d --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/ImageProcessingScreen.kt @@ -0,0 +1,845 @@ +package com.samuel.inventorymanager.screens + +import android.graphics.Bitmap +import android.graphics.Matrix +import android.net.Uri +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +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.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.ArrowForward +import androidx.compose.material.icons.automirrored.filled.RotateRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CropFree +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Tune +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +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.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.samuel.inventorymanager.services.AIService +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +@Composable +fun ImageProcessingScreen( + imageUri: Uri, + onImageProcessed: (Uri, AIService.AIAnalysisResult) -> Unit, + onCancel: () -> Unit, + aiService: AIService +) { + var currentStep by remember { mutableStateOf(ProcessingStep.EDITING) } + var editedBitmap by remember { mutableStateOf(null) } + var aiResult by remember { mutableStateOf(null) } + + Box(modifier = Modifier.fillMaxSize()) { + when (currentStep) { + ProcessingStep.EDITING -> { + ImageEditorScreen( + imageUri = imageUri, + onNext = { bitmap -> + editedBitmap = bitmap + currentStep = ProcessingStep.AI_ANALYZING + }, + onCancel = onCancel + ) + } + ProcessingStep.AI_ANALYZING -> { + LaunchedEffect(Unit) { + delay(500) + try { + val result = aiService.analyzeItemFromBitmap(editedBitmap!!) + aiResult = result + currentStep = ProcessingStep.AI_PREVIEW + } catch (_: Exception) { + onImageProcessed( + imageUri, + AIService.AIAnalysisResult( + itemName = null, + confidence = 0.0, + modelNumber = null, + description = null, + estimatedPrice = null, + condition = null, + sizeCategory = null, + dimensions = null, + rawText = null + ) + ) + } + } + AIAnalyzingScreen() + } + ProcessingStep.AI_PREVIEW -> { + aiResult?.let { result -> + editedBitmap?.let { bitmap -> + AIPreviewScreen( + result = result, + bitmap = bitmap, + onAccept = { finalResult -> + onImageProcessed(imageUri, finalResult) + }, + onEdit = { finalResult -> + onImageProcessed(imageUri, finalResult) + } + ) + } + } + } + } + } +} + +enum class ProcessingStep { + EDITING, AI_ANALYZING, AI_PREVIEW +} + +@Composable +fun ImageEditorScreen( + imageUri: Uri, + onNext: (Bitmap) -> Unit, + onCancel: () -> Unit +) { + val context = LocalContext.current + var bitmap by remember { mutableStateOf(null) } + var rotation by remember { mutableFloatStateOf(0f) } + var brightness by remember { mutableFloatStateOf(0f) } + var contrast by remember { mutableFloatStateOf(1f) } + var scale by remember { mutableFloatStateOf(1f) } + var currentTool by remember { mutableStateOf(EditorTool.ROTATE) } + + var showRotationDialog by remember { mutableStateOf(false) } + var showBrightnessDialog by remember { mutableStateOf(false) } + var showContrastDialog by remember { mutableStateOf(false) } + + LaunchedEffect(imageUri) { + bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + loadBitmapFromUri(context, imageUri) + } else { + loadBitmapFromUriLegacy(context, imageUri) + } + } + + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) { + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + EditorTopBar( + onCancel = onCancel, + onNext = { + bitmap?.let { bmp -> + val processed = applyEdits(bmp, rotation, brightness, contrast) + onNext(processed) + } + }, + modifier = Modifier.zIndex(10f) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + bitmap?.let { bmp -> + EditableImage( + bitmap = bmp, + rotation = rotation, + brightness = brightness, + contrast = contrast, + scale = scale, + onScaleChange = { scale = it }, + onResetView = { + scale = 1f + rotation = 0f + } + ) + } + } + + EditorToolsPanel( + currentTool = currentTool, + onToolChange = { currentTool = it }, + rotation = rotation, + onRotationChange = { rotation = it }, + brightness = brightness, + onBrightnessChange = { brightness = it }, + contrast = contrast, + onContrastChange = { contrast = it }, + onRotationClick = { showRotationDialog = true }, + onBrightnessClick = { showBrightnessDialog = true }, + onContrastClick = { showContrastDialog = true }, + modifier = Modifier.zIndex(10f) + ) + } + + if (showRotationDialog) { + NumberInputDialog( + title = "Rotation", + currentValue = rotation, + onDismiss = { showRotationDialog = false }, + onConfirm = { + rotation = it.coerceIn(-180f, 180f) + showRotationDialog = false + }, + suffix = "°" + ) + } + + if (showBrightnessDialog) { + NumberInputDialog( + title = "Brightness", + currentValue = brightness * 100, + onDismiss = { showBrightnessDialog = false }, + onConfirm = { + brightness = (it / 100f).coerceIn(-1f, 1f) + showBrightnessDialog = false + }, + suffix = "" + ) + } + + if (showContrastDialog) { + NumberInputDialog( + title = "Contrast", + currentValue = contrast * 100, + onDismiss = { showContrastDialog = false }, + onConfirm = { + contrast = (it / 100f).coerceIn(0f, 2f) + showContrastDialog = false + }, + suffix = "%" + ) + } + } + } +} + +@Composable +fun EditorTopBar(onCancel: () -> Unit, onNext: () -> Unit, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + color = Color(0xFF1A1A1A), + shadowElevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onCancel) { + Icon(Icons.Default.Close, null, tint = Color.White) + Spacer(Modifier.width(4.dp)) + Text("Cancel", color = Color.White, fontWeight = FontWeight.Medium) + } + Text("Edit Image", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Button( + onClick = onNext, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)), + shape = RoundedCornerShape(12.dp) + ) { + Text("Next", fontWeight = FontWeight.Bold) + Spacer(Modifier.width(4.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowForward, null) + } + } + } +} + +@Composable +fun EditableImage( + bitmap: Bitmap, + rotation: Float, + brightness: Float, + contrast: Float, + scale: Float, + onScaleChange: (Float) -> Unit, + onResetView: () -> Unit +) { + val filteredBitmap by remember { + derivedStateOf { + applyFilters(bitmap, brightness, contrast).asImageBitmap() + } + } + + Box(modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(16.dp))) { + Box( + modifier = Modifier + .fillMaxSize(0.95f) + .align(Alignment.Center) + .clip(RoundedCornerShape(12.dp)) + ) { + Image( + bitmap = filteredBitmap, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .graphicsLayer(rotationZ = rotation, scaleX = scale, scaleY = scale) + .pointerInput(Unit) { + detectTransformGestures { _, _, zoom, _ -> + onScaleChange((scale * zoom).coerceIn(0.5f, 3f)) + } + }, + contentScale = ContentScale.Fit + ) + } + + IconButton( + onClick = onResetView, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .background(Color(0xFF2A2A2A), RoundedCornerShape(12.dp)) + ) { + Icon(Icons.Default.CropFree, contentDescription = "Reset View", tint = Color.White) + } + } +} + +enum class EditorTool { ROTATE, ADJUST } + +@Composable +fun EditorToolsPanel( + currentTool: EditorTool, + onToolChange: (EditorTool) -> Unit, + rotation: Float, + onRotationChange: (Float) -> Unit, + brightness: Float, + onBrightnessChange: (Float) -> Unit, + contrast: Float, + onContrastChange: (Float) -> Unit, + onRotationClick: () -> Unit, + onBrightnessClick: () -> Unit, + onContrastClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = Color(0xFF1A1A1A), + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + EditorTool.entries.forEach { tool -> + ToolButton(tool = tool, isSelected = currentTool == tool, onClick = { onToolChange(tool) }) + } + } + + AnimatedContent(targetState = currentTool, label = "tool_controls") { tool -> + when (tool) { + EditorTool.ROTATE -> { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF2A2A2A)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Rotation:", color = Color.White, fontSize = 14.sp) + TextButton(onClick = onRotationClick) { + Text( + "${rotation.toInt()}°", + color = Color(0xFF6366F1), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + Slider( + value = rotation, + onValueChange = onRotationChange, + valueRange = -180f..180f, + colors = SliderDefaults.colors( + thumbColor = Color(0xFF6366F1), + activeTrackColor = Color(0xFF6366F1) + ) + ) + } + } + EditorTool.ADJUST -> { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF2A2A2A)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Brightness:", color = Color.White, fontSize = 14.sp) + TextButton(onClick = onBrightnessClick) { + Text( + "${(brightness * 100).toInt()}", + color = Color(0xFFFBBF24), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + Slider( + value = brightness, + onValueChange = onBrightnessChange, + valueRange = -1f..1f, + colors = SliderDefaults.colors( + thumbColor = Color(0xFFFBBF24), + activeTrackColor = Color(0xFFFBBF24) + ) + ) + } + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF2A2A2A)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Contrast:", color = Color.White, fontSize = 14.sp) + TextButton(onClick = onContrastClick) { + Text( + "${(contrast * 100).toInt()}%", + color = Color(0xFF8B5CF6), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + Slider( + value = contrast, + onValueChange = onContrastChange, + valueRange = 0f..2f, + colors = SliderDefaults.colors( + thumbColor = Color(0xFF8B5CF6), + activeTrackColor = Color(0xFF8B5CF6) + ) + ) + } + } + } + } + } + } + } +} + +@Composable +fun ToolButton(tool: EditorTool, isSelected: Boolean, onClick: () -> Unit) { + val (icon, label) = when (tool) { + EditorTool.ROTATE -> Icons.AutoMirrored.Filled.RotateRight to "Rotate" + EditorTool.ADJUST -> Icons.Default.Tune to "Adjust" + } + + Surface( + onClick = onClick, + shape = RoundedCornerShape(16.dp), + color = if (isSelected) Color(0xFF6366F1) else Color(0xFF2A2A2A), + modifier = Modifier.padding(4.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(icon, null, tint = Color.White, modifier = Modifier.size(28.dp)) + Text( + label, + color = Color.White, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + } +} + +@Composable +fun NumberInputDialog( + title: String, + currentValue: Float, + onDismiss: () -> Unit, + onConfirm: (Float) -> Unit, + suffix: String +) { + var textValue by remember { mutableStateOf(currentValue.toInt().toString()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Set $title", color = Color.White, fontWeight = FontWeight.Bold) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Enter value:", color = Color.White.copy(alpha = 0.7f), fontSize = 14.sp) + OutlinedTextField( + value = textValue, + onValueChange = { textValue = it }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF6366F1), + unfocusedBorderColor = Color(0xFF3A3A3A), + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + cursorColor = Color(0xFF6366F1) + ), + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + suffix = { Text(suffix, color = Color.White.copy(alpha = 0.6f)) }, + singleLine = true + ) + } + }, + confirmButton = { + Button( + onClick = { + val value = textValue.toFloatOrNull() ?: currentValue + onConfirm(value) + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)), + shape = RoundedCornerShape(12.dp) + ) { + Text("Apply", fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel", color = Color.White.copy(alpha = 0.7f)) + } + }, + containerColor = Color(0xFF1A1A1A), + shape = RoundedCornerShape(20.dp) + ) +} + +@Composable +fun AIAnalyzingScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF0A0A0A)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + val infiniteTransition = rememberInfiniteTransition(label = "ai_pulse") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "scale" + ) + + Icon( + Icons.Default.Psychology, + contentDescription = null, + modifier = Modifier + .size(80.dp) + .graphicsLayer(scaleX = scale, scaleY = scale), + tint = Color(0xFF6366F1) + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("AI Analyzing Image...", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) + Text( + "Extracting item details automatically", + color = Color.White.copy(alpha = 0.6f), + fontSize = 14.sp + ) + } + + CircularProgressIndicator(color = Color(0xFF6366F1), modifier = Modifier.size(48.dp)) + } + } +} + +@Composable +fun AIPreviewScreen( + result: AIService.AIAnalysisResult, + bitmap: Bitmap, + onAccept: (AIService.AIAnalysisResult) -> Unit, + onEdit: (AIService.AIAnalysisResult) -> Unit +) { + var editedResult by remember { mutableStateOf(result) } + var isEditing by remember { mutableStateOf(false) } + + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0F0F0F)) { + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier + .fillMaxWidth() + .height(200.dp)) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + alpha = 0.4f + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient(colors = listOf(Color.Transparent, Color(0xFF0F0F0F))) + ) + ) + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("✨ AI Detected (AI is not perfect)", color = Color(0xFF6366F1), fontSize = 22.sp, fontWeight = FontWeight.Bold) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A)) + ) { + Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Detected Information", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) + IconButton(onClick = { isEditing = !isEditing }) { + Icon( + if (isEditing) Icons.Default.Check else Icons.Default.Edit, + null, + tint = Color(0xFF6366F1) + ) + } + } + + AIField("Item Name", editedResult.itemName, isEditing) { + editedResult = editedResult.copy(itemName = it) + } + AIField("Model", editedResult.modelNumber, isEditing) { + editedResult = editedResult.copy(modelNumber = it) + } + AIField("Description", editedResult.description, isEditing, maxLines = 3) { + editedResult = editedResult.copy(description = it) + } + AIField("Condition", editedResult.condition, isEditing) { + editedResult = editedResult.copy(condition = it) + } + AIField("Size Category", editedResult.sizeCategory, isEditing) { + editedResult = editedResult.copy(sizeCategory = it) + } + editedResult.estimatedPrice?.let { price -> + AIField("Est. Price", "$${price}", isEditing) { + editedResult = editedResult.copy(estimatedPrice = it.replace("$", "").toDoubleOrNull()) + } + } + } + } + } + + Surface(modifier = Modifier.fillMaxWidth(), color = Color(0xFF1A1A1A), shadowElevation = 8.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { onEdit(editedResult) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2A2A2A)), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(18.dp) + ) { + Icon(Icons.Default.Edit, null) + Spacer(Modifier.width(8.dp)) + Text("Edit More", fontSize = 15.sp, fontWeight = FontWeight.Bold) + } + Button( + onClick = { onAccept(editedResult) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF10B981)), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(18.dp) + ) { + Icon(Icons.Default.Check, null) + Spacer(Modifier.width(8.dp)) + Text("Accept", fontSize = 15.sp, fontWeight = FontWeight.Bold) + } + } + } + } + } +} + +@Composable +fun AIField( + label: String, + value: String?, + isEditing: Boolean, + maxLines: Int = 1, + onValueChange: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(label, color = Color.White.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Medium) + if (isEditing) { + OutlinedTextField( + value = value ?: "", + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF6366F1), + unfocusedBorderColor = Color(0xFF2A2A2A), + focusedTextColor = Color.White, + unfocusedTextColor = Color.White + ), + shape = RoundedCornerShape(12.dp), + maxLines = maxLines + ) + } else { + Text( + value ?: "Not detected", + color = if (value != null) Color.White else Color.White.copy(alpha = 0.4f), + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES.P) +suspend fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? { + return try { + withContext(kotlinx.coroutines.Dispatchers.IO) { + val source = android.graphics.ImageDecoder.createSource(context.contentResolver, uri) + android.graphics.ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.isMutableRequired = true + } + } + } catch (_: Exception) { // Changed from (e: Exception) to (_: Exception) + null + } +} +@Suppress("DEPRECATION") +suspend fun loadBitmapFromUriLegacy(context: android.content.Context, uri: Uri): Bitmap? { + return try { + withContext(kotlinx.coroutines.Dispatchers.IO) { + android.provider.MediaStore.Images.Media.getBitmap(context.contentResolver, uri) + } + } catch (_: Exception) { + null + } +} +fun applyFilters(bitmap: Bitmap, brightness: Float, contrast: Float): Bitmap { + val colorMatrix = android.graphics.ColorMatrix().apply { + set(floatArrayOf( + contrast, 0f, 0f, 0f, brightness * 255, + 0f, contrast, 0f, 0f, brightness * 255, + 0f, 0f, contrast, 0f, brightness * 255, + 0f, 0f, 0f, 1f, 0f + )) + } + val paint = android.graphics.Paint().apply { + colorFilter = android.graphics.ColorMatrixColorFilter(colorMatrix) + } + val safeConfig = bitmap.config ?: Bitmap.Config.ARGB_8888 + // Using androidx.core.graphics.createBitmap extension function + val result = androidx.core.graphics.createBitmap(bitmap.width, bitmap.height, safeConfig) + val canvas = android.graphics.Canvas(result) + canvas.drawBitmap(bitmap, 0f, 0f, paint) + return result +} + +fun applyEdits(bitmap: Bitmap, rotation: Float, brightness: Float, contrast: Float): Bitmap { + var result = applyFilters(bitmap, brightness, contrast) + + if (rotation != 0f) { + val matrix = Matrix().apply { postRotate(rotation) } + result = Bitmap.createBitmap(result, 0, 0, result.width, result.height, matrix, true) + } + + return result +} \ 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 index a8c4af9..b10425c 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/ImagesScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/ImagesScreen.kt @@ -1,6 +1,5 @@ package com.samuel.inventorymanager.screens -import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -8,130 +7,602 @@ 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.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight 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.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ImageSearch +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.ZoomIn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +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.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext 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.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri import coil.compose.rememberAsyncImagePainter +import com.google.gson.Gson +import java.io.File + +// ======================================================================================== +// DELETED IMAGE TRACKING +// ======================================================================================== + +data class DeletedImageEntry( + val imageUrl: String, + val itemId: String, + val deletedAt: Long = System.currentTimeMillis() +) + +data class DeletedImagesData( + val deletedImages: List = emptyList() +) -// 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 + val imageUrl: String, + val isDeleted: Boolean = false ) +enum class ImageSortOption { + NAME_ASC, + NAME_DESC, + DATE_NEWEST, + DATE_OLDEST, + CATEGORY_ASC, + CATEGORY_DESC +} + +private fun saveDeletedImages(context: android.content.Context, deletedImages: List) { + try { + context.openFileOutput("deleted_images.json", android.content.Context.MODE_PRIVATE).use { + it.write(Gson().toJson(DeletedImagesData(deletedImages)).toByteArray()) + } + } catch (_: Exception) { + // Handle silently + } +} + +private fun loadDeletedImages(context: android.content.Context): List { + val file = File(context.filesDir, "deleted_images.json") + return if (file.exists()) { + try { + val data = Gson().fromJson(file.readText(), DeletedImagesData::class.java) + // Remove images that were deleted more than 2 days ago + val twoDaysMs = 2 * 24 * 60 * 60 * 1000L + val now = System.currentTimeMillis() + data.deletedImages.filter { now - it.deletedAt < twoDaysMs } + } catch (_: Exception) { + emptyList() + } + } else { + emptyList() + } +} + +// ======================================================================================== +// MAIN IMAGES SCREEN +// ======================================================================================== + @Composable fun ImagesScreen( items: List, - onItemClick: (Item) -> Unit // This function will trigger the navigation + onItemClick: (Item) -> Unit ) { - // 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) + val context = LocalContext.current + var searchQuery by remember { mutableStateOf("") } + var deletedImages by remember { mutableStateOf(loadDeletedImages(context)) } + var selectedImageEntry by remember { mutableStateOf(null) } + var showZoomDialog by remember { mutableStateOf(false) } + var sortOption by remember { mutableStateOf(ImageSortOption.NAME_ASC) } + var showSortMenu by remember { mutableStateOf(false) } + + // Filter items to only include those with images + val itemsWithImages = remember(items) { + items.filter { it.images.isNotEmpty() } + } + + // Gather all images and mark deleted ones + val allImages = remember(itemsWithImages, deletedImages) { + itemsWithImages.flatMap { item -> + item.images.map { imageUrl -> + val isDeleted = deletedImages.any { it.imageUrl == imageUrl && it.itemId == item.id } + ImageEntry(item = item, imageUrl = imageUrl, isDeleted = isDeleted) + } } } - 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) + // Filter by search query + val filteredImages = remember(allImages, searchQuery) { + allImages.filter { entry -> + searchQuery.isBlank() || + entry.item.name.contains(searchQuery, ignoreCase = true) || + entry.item.modelNumber?.contains(searchQuery, ignoreCase = true) == true + } + } + + // Sort images + val sortedImages = remember(filteredImages, sortOption) { + when (sortOption) { + ImageSortOption.NAME_ASC -> filteredImages.sortedBy { it.item.name.lowercase() } + ImageSortOption.NAME_DESC -> filteredImages.sortedByDescending { it.item.name.lowercase() } + ImageSortOption.DATE_NEWEST -> { + // Sort by item index in reverse (newest items first) + val itemIndices = items.mapIndexed { index, item -> item.id to index }.toMap() + filteredImages.sortedByDescending { itemIndices[it.item.id] ?: 0 } + } + ImageSortOption.DATE_OLDEST -> { + // Sort by item index (oldest items first) + val itemIndices = items.mapIndexed { index, item -> item.id to index }.toMap() + filteredImages.sortedBy { itemIndices[it.item.id] ?: 0 } + } + ImageSortOption.CATEGORY_ASC -> filteredImages.sortedBy { it.item.sizeCategory.lowercase() } + ImageSortOption.CATEGORY_DESC -> filteredImages.sortedByDescending { it.item.sizeCategory.lowercase() } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Header with Search and Sort + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(0.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.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) + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .weight(1f) + .height(48.dp), + placeholder = { Text("Search items...") }, + 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) + ) + + // Sort button + Box { + Button( + onClick = { showSortMenu = true }, + modifier = Modifier.height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Icon( + Icons.AutoMirrored.Filled.Sort, + contentDescription = "Sort", + modifier = Modifier.size(18.dp) + ) + } + + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Name") + Spacer(Modifier.width(8.dp)) + Icon(Icons.Default.ArrowUpward, null, modifier = Modifier.size(16.dp)) + } + }, + onClick = { + sortOption = ImageSortOption.NAME_ASC + showSortMenu = false + } + ) + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Name") + Spacer(Modifier.width(8.dp)) + Icon(Icons.Default.ArrowDownward, null, modifier = Modifier.size(16.dp)) + } + }, + onClick = { + sortOption = ImageSortOption.NAME_DESC + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Newest First") }, + onClick = { + sortOption = ImageSortOption.DATE_NEWEST + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Oldest First") }, + onClick = { + sortOption = ImageSortOption.DATE_OLDEST + showSortMenu = false + } + ) + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Size Category") + Spacer(Modifier.width(8.dp)) + Icon(Icons.Default.ArrowUpward, null, modifier = Modifier.size(16.dp)) + } + }, + onClick = { + sortOption = ImageSortOption.CATEGORY_ASC + showSortMenu = false + } + ) + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Size Category") + Spacer(Modifier.width(8.dp)) + Icon(Icons.Default.ArrowDownward, null, modifier = Modifier.size(16.dp)) + } + }, + onClick = { + sortOption = ImageSortOption.CATEGORY_DESC + showSortMenu = false + } + ) + } } - ) + + // Clean deleted images button + Button( + onClick = { + deletedImages = emptyList() + saveDeletedImages(context, emptyList()) + }, + modifier = Modifier.height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + enabled = deletedImages.isNotEmpty() + ) { + Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + } + } + + // Results + if (sortedImages.isEmpty()) { + EmptyState() + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(sortedImages) { imageEntry -> + ImageCard( + imageEntry = imageEntry, + onImageClick = { + selectedImageEntry = imageEntry + showZoomDialog = true + }, + onEditClick = { + onItemClick(imageEntry.item) + }, + onDeleteClick = { + // Mark image as deleted + val newDeletedEntry = DeletedImageEntry( + imageUrl = imageEntry.imageUrl, + itemId = imageEntry.item.id, + deletedAt = System.currentTimeMillis() + ) + deletedImages = deletedImages + newDeletedEntry + saveDeletedImages(context, deletedImages) + } + ) + } } } } -} + // Zoom Dialog + if (showZoomDialog && selectedImageEntry != null) { + ZoomImageDialog( + imageEntry = selectedImageEntry!!, + onDismiss = { showZoomDialog = false }, + onEditClick = { + onItemClick(selectedImageEntry!!.item) + showZoomDialog = false + } + ) + } +} -// ====================================================================== -// HELPER COMPOSABLES -// These are the building blocks that make the screen look polished. ✨ -// ====================================================================== +// ======================================================================================== +// HELPER COMPOSABLES +// ======================================================================================== @Composable private fun ImageCard( imageEntry: ImageEntry, - onClick: () -> Unit + onImageClick: () -> Unit, + onEditClick: () -> Unit, + onDeleteClick: () -> 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 - ) + Box(modifier = Modifier.aspectRatio(1f)) { + Card( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onImageClick), + elevation = CardDefaults.cardElevation(4.dp), + shape = MaterialTheme.shapes.medium + ) { + Box(modifier = Modifier.fillMaxSize()) { + // Image + Image( + painter = rememberAsyncImagePainter(model = imageEntry.imageUrl.toUri()), + contentDescription = imageEntry.item.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) - // 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) + // Deleted overlay + if (imageEntry.isDeleted) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = Color.Red, + modifier = Modifier.size(40.dp) + ) + Spacer(Modifier.height(8.dp)) + Text( + "Deleted", + color = Color.Red, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall + ) + } + } } - ) - // The name of the item the image belongs to - Text( - text = imageEntry.item.name, + // Gradient overlay for text + Box( + modifier = Modifier + .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) + ) + + // Item name + Text( + text = imageEntry.item.name, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), + color = Color.White, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall + ) + } + } + + // Action buttons (top right) + if (!imageEntry.isDeleted) { + Row( modifier = Modifier - .align(Alignment.BottomStart) + .align(Alignment.TopEnd) .padding(8.dp), - color = Color.White, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Zoom button + IconButton( + onClick = onImageClick, + modifier = Modifier + .size(32.dp) + .background( + Color.Black.copy(alpha = 0.6f), + RoundedCornerShape(6.dp) + ) + ) { + Icon( + Icons.Default.ZoomIn, + contentDescription = "Zoom", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + + // Edit button + IconButton( + onClick = onEditClick, + modifier = Modifier + .size(32.dp) + .background( + Color.Black.copy(alpha = 0.6f), + RoundedCornerShape(6.dp) + ) + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + } + } else { + // Show delete button for deleted images (to allow permanent removal if needed) + @Suppress("UNUSED_EXPRESSION") + onDeleteClick // Keep parameter to avoid warning + } + } +} + +@Composable +private fun ZoomImageDialog( + imageEntry: ImageEntry, + onDismiss: () -> Unit, + onEditClick: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.95f)) + ) { + // Close button (top left) + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + + // Edit button (top right) + IconButton( + onClick = onEditClick, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit Item", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + + // Main image + Image( + painter = rememberAsyncImagePainter(model = imageEntry.imageUrl.toUri()), + contentDescription = imageEntry.item.name, + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onDismiss), + contentScale = ContentScale.Fit ) + + // Item info at bottom + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background( + Color.Black.copy(alpha = 0.7f), + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + ) + .padding(16.dp) + ) { + Text( + text = imageEntry.item.name, + color = Color.White, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + if (imageEntry.item.modelNumber != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = "Model: ${imageEntry.item.modelNumber}", + color = Color.White.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall + ) + } + if (imageEntry.item.description != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = imageEntry.item.description, + color = Color.White.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } } } } @@ -139,7 +610,9 @@ private fun ImageCard( @Composable private fun EmptyState() { Box( - modifier = Modifier.fillMaxSize().padding(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp), contentAlignment = Alignment.Center ) { Column( diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/LocationsScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/LocationsScreen.kt index 0619484..0d5a8f8 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/LocationsScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/LocationsScreen.kt @@ -1,7 +1,16 @@ package com.samuel.inventorymanager.screens import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke +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 @@ -10,6 +19,8 @@ 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.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -17,20 +28,36 @@ 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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.GridView 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.material.icons.filled.Preview +import androidx.compose.material.icons.filled.ViewAgenda +import androidx.compose.material.icons.filled.ViewModule +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.DropdownMenu @@ -39,9 +66,12 @@ import androidx.compose.material3.FloatingActionButton 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.Scaffold 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.getValue @@ -50,10 +80,19 @@ 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.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil.compose.rememberAsyncImagePainter import java.util.Locale @Composable @@ -66,193 +105,2842 @@ fun LocationsScreen( onAddBox: (shelfId: String) -> Unit, onRenameLocation: (id: String, oldName: String, type: String) -> Unit, onDeleteLocation: (id: String, name: String, type: String) -> Unit +) { + var isPreviewMode by remember { mutableStateOf(false) } + + if (isPreviewMode) { + PreviewMode( + garages = garages, + items = items, + onBackToEdit = { isPreviewMode = false } + ) + } else { + EditMode( + garages = garages, + items = items, + onAddGarage = onAddGarage, + onAddCabinet = onAddCabinet, + onAddShelf = onAddShelf, + onAddBox = onAddBox, + onRenameLocation = onRenameLocation, + onDeleteLocation = onDeleteLocation, + onSwitchToPreview = { isPreviewMode = true } + ) + } +} + +// ======================================================================================== +// EDIT MODE +// ======================================================================================== + +@Composable +private fun EditMode( + 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, + onSwitchToPreview: () -> Unit +) { + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + "Edit Locations", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + "${garages.size} garages • ${items.size} items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton( + onClick = onSwitchToPreview, + modifier = Modifier + .background( + MaterialTheme.colorScheme.primaryContainer, + RoundedCornerShape(12.dp) + ) + ) { + Icon( + Icons.Default.Preview, + "Preview Mode", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + }, + floatingActionButton = { + FloatingActionButton( + onClick = onAddGarage, + containerColor = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.shadow(8.dp, RoundedCornerShape(16.dp)) + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add Garage", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + ) { paddingValues -> + if (garages.isEmpty()) { + EmptyState(onAddGarage, modifier = Modifier.padding(paddingValues)) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.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 + ) + } + } + } + } +} + +// ======================================================================================== +// MULTI-ADD DIALOG +// ======================================================================================== + +@Composable +private fun MultiAddDialog( + title: String, + onDismiss: () -> Unit, + onConfirm: (count: Int) -> Unit +) { + var count by remember { mutableStateOf("1") } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Box( + modifier = Modifier + .size(56.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Add, + null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(32.dp) + ) + } + }, + title = { + Text( + title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + "How many would you like to add?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedTextField( + value = count, + onValueChange = { + if (it.isEmpty() || it.toIntOrNull() != null) { + count = it + } + }, + label = { Text("Quantity") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) + + // Quick select buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf(1, 3, 5, 10).forEach { num -> + OutlinedButton( + onClick = { count = num.toString() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (count == num.toString()) + MaterialTheme.colorScheme.primaryContainer + else + Color.Transparent + ) + ) { + Text(num.toString()) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { + val num = count.toIntOrNull() + if (num != null && num > 0) { + onConfirm(num) + onDismiss() + } + }, + shape = RoundedCornerShape(12.dp) + ) { + Text("Add") + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(12.dp) + ) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(24.dp) + ) +} + +// ======================================================================================== +// PREVIEW MODE +// ======================================================================================== + +private enum class PreviewStyle { + DRILL_DOWN, COLLAPSIBLE +} + +private enum class ItemViewMode { + GRID, LIST +} + +@Composable +private fun PreviewMode( + garages: List, + items: List, + onBackToEdit: () -> Unit +) { + var previewStyle by remember { mutableStateOf(PreviewStyle.DRILL_DOWN) } + + when (previewStyle) { + PreviewStyle.DRILL_DOWN -> { + DrillDownNavigationHost( + garages = garages, + items = items, + onBackToEdit = onBackToEdit, + onSwitchStyle = { previewStyle = PreviewStyle.COLLAPSIBLE } + ) + } + PreviewStyle.COLLAPSIBLE -> { + CollapsibleListPreview( + garages = garages, + items = items, + onBackToEdit = onBackToEdit, + onSwitchStyle = { previewStyle = PreviewStyle.DRILL_DOWN } + ) + } + } +} + +// ======================================================================================== +// DRILL-DOWN PREVIEW +// ======================================================================================== + +@Composable +private fun DrillDownNavigationHost( + garages: List, + items: List, + onBackToEdit: () -> Unit, + onSwitchStyle: () -> Unit +) { + var selectedGarageId by remember { mutableStateOf(null) } + var selectedCabinetId by remember { mutableStateOf(null) } + var selectedShelfId by remember { mutableStateOf(null) } + var selectedBoxId by remember { mutableStateOf(null) } + var boxLayout by remember { mutableStateOf(BoxLayout.SCATTERED) } + var showAllItemsInGarage by remember { mutableStateOf(false) } + var showAllItemsInCabinet by remember { mutableStateOf(false) } + var showAllItemsInShelf by remember { mutableStateOf(false) } + + when { + selectedBoxId != null -> { + val garage = garages.find { it.id == selectedGarageId } + val cabinet = garage?.cabinets?.find { it.id == selectedCabinetId } + val shelf = cabinet?.shelves?.find { it.id == selectedShelfId } + val box = shelf?.boxes?.find { it.id == selectedBoxId } + val itemsInBox = items.filter { it.boxId == selectedBoxId } + + if (box != null) { + BoxPreviewScreen( + box = box, + itemsInBox = itemsInBox, + boxLayout = boxLayout, + onLayoutChange = { boxLayout = it }, + onBack = { + selectedBoxId = null + showAllItemsInShelf = false + } + ) + } + } + showAllItemsInShelf && selectedShelfId != null -> { + val garage = garages.find { it.id == selectedGarageId } + val cabinet = garage?.cabinets?.find { it.id == selectedCabinetId } + val shelf = cabinet?.shelves?.find { it.id == selectedShelfId } + val itemsInShelf = items.filter { it.shelfId == selectedShelfId } + + if (shelf != null) { + AllItemsInLocationScreen( + locationName = shelf.name, + locationType = "Shelf", + items = itemsInShelf, + onBack = { showAllItemsInShelf = false } + ) + } + } + selectedShelfId != null -> { + val garage = garages.find { it.id == selectedGarageId } + val cabinet = garage?.cabinets?.find { it.id == selectedCabinetId } + val shelf = cabinet?.shelves?.find { it.id == selectedShelfId } + val itemsInShelf = items.filter { it.shelfId == selectedShelfId } + val boxesInShelf = shelf?.boxes ?: emptyList() + + if (shelf != null) { + ShelfPreviewScreen( + shelf = shelf, + itemsInShelf = itemsInShelf, + boxesInShelf = boxesInShelf, + onBoxClick = { boxId -> selectedBoxId = boxId }, + onShowAllItems = { showAllItemsInShelf = true }, + onBack = { + selectedShelfId = null + showAllItemsInCabinet = false + } + ) + } + } + showAllItemsInCabinet && selectedCabinetId != null -> { + val garage = garages.find { it.id == selectedGarageId } + val cabinet = garage?.cabinets?.find { it.id == selectedCabinetId } + val itemsInCabinet = items.filter { it.cabinetId == selectedCabinetId } + + if (cabinet != null) { + AllItemsInLocationScreen( + locationName = cabinet.name, + locationType = "Cabinet", + items = itemsInCabinet, + onBack = { showAllItemsInCabinet = false } + ) + } + } + selectedCabinetId != null -> { + val garage = garages.find { it.id == selectedGarageId } + val cabinet = garage?.cabinets?.find { it.id == selectedCabinetId } + val itemsInCabinet = items.filter { it.cabinetId == selectedCabinetId } + + if (cabinet != null) { + CabinetPreviewScreen( + cabinet = cabinet, + itemsInCabinet = itemsInCabinet, + onShelfClick = { shelfId -> selectedShelfId = shelfId }, + onShowAllItems = { showAllItemsInCabinet = true }, + onBack = { + selectedCabinetId = null + showAllItemsInGarage = false + } + ) + } + } + showAllItemsInGarage && selectedGarageId != null -> { + val garage = garages.find { it.id == selectedGarageId } + val itemsInGarage = items.filter { it.garageId == selectedGarageId } + + if (garage != null) { + AllItemsInLocationScreen( + locationName = garage.name, + locationType = "Garage", + items = itemsInGarage, + onBack = { showAllItemsInGarage = false } + ) + } + } + selectedGarageId != null -> { + val garage = garages.find { it.id == selectedGarageId } + val itemsInGarage = items.filter { it.garageId == selectedGarageId } + + if (garage != null) { + GarageCabinetsScreen( + garage = garage, + itemsInGarage = itemsInGarage, + onCabinetClick = { cabinetId -> selectedCabinetId = cabinetId }, + onShowAllItems = { showAllItemsInGarage = true }, + onBack = { selectedGarageId = null } + ) + } + } + else -> { + GaragePreviewScreen( + garages = garages, + items = items, + onGarageClick = { garageId -> selectedGarageId = garageId }, + onBackToEdit = onBackToEdit, + onSwitchStyle = onSwitchStyle + ) + } + } +} + +// ======================================================================================== +// ALL ITEMS IN LOCATION SCREEN +// ======================================================================================== + +@Composable +private fun AllItemsInLocationScreen( + locationName: String, + locationType: String, + items: List, + onBack: () -> Unit +) { + var viewMode by remember { mutableStateOf(ItemViewMode.GRID) } + + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ) + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Column(modifier = Modifier.weight(1f)) { + Text( + "All Items", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + "in $locationName", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), + modifier = Modifier.weight(1f) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Inventory2, + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Column { + Text( + "${items.size}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + "Total Items", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + } + } + + IconButton( + onClick = { + viewMode = if (viewMode == ItemViewMode.GRID) + ItemViewMode.LIST + else + ItemViewMode.GRID + }, + modifier = Modifier + .background( + MaterialTheme.colorScheme.secondaryContainer, + RoundedCornerShape(12.dp) + ) + .size(56.dp) + ) { + Icon( + if (viewMode == ItemViewMode.GRID) + Icons.Default.ViewAgenda + else + Icons.Default.ViewModule, + "Toggle View", + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + } + } + ) { paddingValues -> + if (items.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Inventory, + null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Text( + "No items in this $locationType", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + when (viewMode) { + ItemViewMode.GRID -> { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 150.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(items) { item -> + EnhancedItemCard(item = item) + } + } + } + ItemViewMode.LIST -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(items) { item -> + EnhancedItemListRow(item) + } + } + } + } + } + } +} + +// ======================================================================================== +// ENHANCED ITEM CARDS +// ======================================================================================== + +@Composable +private fun EnhancedItemCard(item: Item) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.8f), + elevation = CardDefaults.cardElevation(4.dp), + shape = RoundedCornerShape(16.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (item.images.isNotEmpty()) { + Image( + painter = rememberAsyncImagePainter(model = item.images[0].toUri()), + contentDescription = item.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Inventory2, + null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.5f) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.9f) + ) + ) + ) + .align(Alignment.BottomCenter) + ) + + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + item.name, + color = Color.White, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + if (item.modelNumber != null) { + Text( + "Model: ${item.modelNumber}", + color = Color.White.copy(alpha = 0.9f), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primary + ) { + Text( + "Qty: ${item.quantity}", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = Color.White, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + + if (item.minPrice != null && item.maxPrice != null) { + val avgPrice = (item.minPrice + item.maxPrice) / 2 + Surface( + shape = RoundedCornerShape(8.dp), + color = Color(0xFF10B981) + ) { + Text( + "$${String.format(Locale.US, "%.0f", avgPrice)}", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = Color.White, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } +} + +@Composable +private fun EnhancedItemListRow(item: Item) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(3.dp), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (item.images.isNotEmpty()) { + Image( + painter = rememberAsyncImagePainter(model = item.images[0].toUri()), + contentDescription = item.name, + modifier = Modifier + .size(90.dp) + .clip(RoundedCornerShape(14.dp)), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .size(90.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Inventory2, + null, + modifier = Modifier.size(45.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + item.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + + if (item.modelNumber != null) { + Text( + "Model: ${item.modelNumber}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Inventory2, + null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + "${item.quantity}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + if (item.minPrice != null && item.maxPrice != null) { + val avgPrice = (item.minPrice + item.maxPrice) / 2 + Surface( + shape = RoundedCornerShape(8.dp), + color = Color(0xFF10B981).copy(alpha = 0.15f) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.AttachMoney, + null, + modifier = Modifier.size(14.dp), + tint = Color(0xFF10B981) + ) + Text( + String.format(Locale.US, "%.0f", avgPrice), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFF10B981) + ) + } + } + } + } + + if (item.description != null && item.description.isNotBlank()) { + Text( + item.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +// ======================================================================================== +// GARAGE PREVIEW SCREENS +// ======================================================================================== + +@Composable +private fun GaragePreviewScreen( + garages: List, + items: List, + onGarageClick: (String) -> Unit, + onBackToEdit: () -> Unit, + onSwitchStyle: () -> Unit +) { + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + IconButton( + onClick = onSwitchStyle, + modifier = Modifier + .background( + MaterialTheme.colorScheme.secondaryContainer, + RoundedCornerShape(12.dp) + ) + ) { + Icon( + Icons.AutoMirrored.Filled.ViewList, + "Switch View", + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + Column { + Text( + "Your Garages", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + "${garages.size} locations", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + IconButton( + onClick = onBackToEdit, + modifier = Modifier + .background( + MaterialTheme.colorScheme.primaryContainer, + RoundedCornerShape(12.dp) + ) + ) { + Icon( + Icons.Default.Edit, + "Edit Mode", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(garages) { garage -> + val itemsInGarage = items.filter { it.garageId == garage.id } + val totalValue = itemsInGarage.sumOf { + ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 * it.quantity + } + + GaragePreviewCard( + garage = garage, + itemCount = itemsInGarage.size, + totalValue = totalValue, + onClick = { onGarageClick(garage.id) } + ) + } + } + } +} + +@Composable +private fun GaragePreviewCard( + garage: Garage, + itemCount: Int, + totalValue: Double, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.85f) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + ), + shape = RoundedCornerShape(20.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .size(64.dp) + .background( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary + ) + ), + shape = RoundedCornerShape(16.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.HomeWork, + null, + modifier = Modifier.size(36.dp), + tint = Color.White + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + garage.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InfoChip( + icon = Icons.Default.Inventory2, + text = itemCount.toString(), + modifier = Modifier.weight(1f) + ) + InfoChip( + icon = Icons.Default.AttachMoney, + text = String.format(Locale.US, "%.0f", totalValue), + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +@Composable +private fun InfoChip( + icon: ImageVector, + text: String, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +} + +@Composable +private fun GarageCabinetsScreen( + garage: Garage, + itemsInGarage: List, + onCabinetClick: (String) -> Unit, + onShowAllItems: () -> Unit, + onBack: () -> Unit +) { + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ) + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Column(modifier = Modifier.weight(1f)) { + Text( + garage.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + "${garage.cabinets.size} cabinets • ${itemsInGarage.size} items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (itemsInGarage.isNotEmpty()) { + Button( + onClick = onShowAllItems, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + Icon( + Icons.Default.ViewModule, + null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + "View All ${itemsInGarage.size} Items in Garage", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(garage.cabinets) { cabinet -> + val itemsInCabinet = itemsInGarage.filter { it.cabinetId == cabinet.id } + CabinetGridCard( + cabinet = cabinet, + itemCount = itemsInCabinet.size, + shelfCount = cabinet.shelves.size, + onClick = { onCabinetClick(cabinet.id) } + ) + } + } + } +} + +@Composable +private fun CabinetGridCard( + cabinet: Cabinet, + itemCount: Int, + shelfCount: Int, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.9f) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation( + defaultElevation = 3.dp, + pressedElevation = 6.dp + ), + shape = RoundedCornerShape(18.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(18.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .size(56.dp) + .background( + MaterialTheme.colorScheme.secondaryContainer, + RoundedCornerShape(14.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Kitchen, + null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + cabinet.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + "$shelfCount shelves • $itemCount items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun CabinetPreviewScreen( + cabinet: Cabinet, + itemsInCabinet: List, + onShelfClick: (String) -> Unit, + onShowAllItems: () -> Unit, + onBack: () -> Unit +) { + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ) + ) { + Icon(Icons.Default.ArrowBack, "Back") + } + Column(modifier = Modifier.weight(1f)) { + Text( + cabinet.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + "${cabinet.shelves.size} shelves", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (itemsInCabinet.isNotEmpty()) { + Button( + onClick = onShowAllItems, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + Icon( + Icons.Default.ViewModule, + null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + "View All ${itemsInCabinet.size} Items in Cabinet", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + items(cabinet.shelves) { shelf -> + val itemsInShelf = itemsInCabinet.filter { it.shelfId == shelf.id } + ShelfPreviewCard( + shelf = shelf, + itemCount = itemsInShelf.size, + onClick = { onShelfClick(shelf.id) } + ) + } + } + } +} + +@Composable +private fun ShelfPreviewCard( + shelf: Shelf, + itemCount: Int, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(130.dp) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp, + pressedElevation = 4.dp + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + MaterialTheme.colorScheme.tertiaryContainer, + RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.AutoMirrored.Filled.ViewList, + null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + + Column { + Text( + shelf.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + "$itemCount items", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ShelfPreviewScreen( + shelf: Shelf, + itemsInShelf: List, + boxesInShelf: List, + onBoxClick: (String) -> Unit, + onShowAllItems: () -> Unit, + onBack: () -> Unit +) { + val looseItems = itemsInShelf.filter { it.boxId == null } + + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ) + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Column(modifier = Modifier.weight(1f)) { + Text( + shelf.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + "${boxesInShelf.size} boxes • ${looseItems.size} loose items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (itemsInShelf.isNotEmpty()) { + Button( + onClick = onShowAllItems, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary + ), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + Icon( + Icons.Default.ViewModule, + null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + "View All ${itemsInShelf.size} Items on Shelf", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 110.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + items(looseItems) { item -> + SmallItemPreviewCard(item = item) + } + + items(boxesInShelf) { box -> + val itemsInBox = itemsInShelf.filter { it.boxId == box.id } + BoxPreviewCard( + box = box, + itemCount = itemsInBox.size, + onClick = { onBoxClick(box.id) } + ) + } + } + } +} + +@Composable +private fun SmallItemPreviewCard(item: Item) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + elevation = CardDefaults.cardElevation(2.dp), + shape = RoundedCornerShape(14.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (item.images.isNotEmpty()) { + Image( + painter = rememberAsyncImagePainter(model = item.images[0].toUri()), + contentDescription = item.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Inventory2, + null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.6f) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.85f) + ) + ) + ) + .align(Alignment.BottomCenter) + ) + + Text( + item.name, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(10.dp), + color = Color.White, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun BoxPreviewCard( + box: Box, + itemCount: Int, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation( + defaultElevation = 3.dp, + pressedElevation = 6.dp + ), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF8B5CF6).copy(alpha = 0.15f) + ), + shape = RoundedCornerShape(14.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(14.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(44.dp) + .background( + Color(0xFF8B5CF6).copy(alpha = 0.3f), + RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Inventory, + null, + modifier = Modifier.size(26.dp), + tint = Color(0xFF8B5CF6) + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + box.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + "$itemCount items", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +enum class BoxLayout { + SCATTERED, NEAT +} + +@Composable +private fun BoxPreviewScreen( + box: Box, + itemsInBox: List, + boxLayout: BoxLayout, + onLayoutChange: (BoxLayout) -> Unit, + onBack: () -> Unit ) { Scaffold( - floatingActionButton = { - FloatingActionButton(onClick = onAddGarage) { - Icon(Icons.Default.Add, contentDescription = "Add Garage") + topBar = { + Surface( + shadowElevation = 3.dp, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ) + ) { + Icon(Icons.Default.ArrowBack, "Back") + } + Column { + Text( + box.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + "${itemsInBox.size} items inside", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + LayoutToggleButton( + text = "Grid View", + isSelected = boxLayout == BoxLayout.SCATTERED, + onClick = { onLayoutChange(BoxLayout.SCATTERED) }, + modifier = Modifier.weight(1f) + ) + LayoutToggleButton( + text = "List View", + isSelected = boxLayout == BoxLayout.NEAT, + onClick = { onLayoutChange(BoxLayout.NEAT) }, + modifier = Modifier.weight(1f) + ) + } + } + } + } + ) { paddingValues -> + if (itemsInBox.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Inventory, + null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Text( + "No items in this box", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + when (boxLayout) { + BoxLayout.SCATTERED -> ScatteredLayout(itemsInBox, paddingValues) + BoxLayout.NEAT -> NeatLayout(itemsInBox, paddingValues) + } + } + } +} + +@Composable +private fun LayoutToggleButton( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Button( + onClick = onClick, + modifier = modifier.height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(12.dp), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = if (isSelected) 4.dp else 0.dp + ) + ) { + Text( + text, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ScatteredLayout(items: List, paddingValues: PaddingValues) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 100.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(items) { item -> + SmallItemPreviewCard(item = item) + } + } +} + +@Composable +private fun NeatLayout(items: List, paddingValues: PaddingValues) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(items) { item -> + NeatLayoutItemRow(item) + } + } +} + +@Composable +private fun NeatLayoutItemRow(item: Item) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(2.dp), + shape = RoundedCornerShape(14.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + if (item.images.isNotEmpty()) { + Image( + painter = rememberAsyncImagePainter(model = item.images[0].toUri()), + contentDescription = item.name, + modifier = Modifier + .size(80.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .size(80.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Inventory2, + null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + item.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + if (item.modelNumber != null) { + Text( + "Model: ${item.modelNumber}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + "Qty: ${item.quantity}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +// ======================================================================================== +// COLLAPSIBLE LIST PREVIEW +// ======================================================================================== + +@Composable +private fun CollapsibleListPreview( + garages: List, + items: List, + onBackToEdit: () -> Unit, + onSwitchStyle: () -> Unit +) { + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + IconButton( + onClick = onSwitchStyle, + modifier = Modifier + .background( + MaterialTheme.colorScheme.secondaryContainer, + RoundedCornerShape(12.dp) + ) + ) { + Icon( + Icons.Default.GridView, + "Switch View", + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + Column { + Text( + "All Locations", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + "${garages.size} garages • ${items.size} items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + IconButton( + onClick = onBackToEdit, + modifier = Modifier + .background( + MaterialTheme.colorScheme.primaryContainer, + RoundedCornerShape(12.dp) + ) + ) { + Icon( + Icons.Default.Edit, + "Edit Mode", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } } } ) { paddingValues -> if (garages.isEmpty()) { - EmptyState(onAddGarage, modifier = Modifier.padding(paddingValues)) + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.LocationCity, + null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Text( + "No locations to preview", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } else { LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentPadding = PaddingValues(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(20.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 + CollapsibleGarageSection(garage, itemsInGarage) + } + } + } + } +} + +@Composable +private fun CollapsibleGarageSection(garage: Garage, itemsInGarage: List) { + 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(3.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + ), + shape = RoundedCornerShape(18.dp), + modifier = Modifier.animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + ) { + Column { + PreviewSectionHeader( + title = garage.name, + icon = Icons.Default.HomeWork, + isExpanded = isExpanded, + onToggleExpand = { isExpanded = !isExpanded } + ) { + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInGarage.size}", small = false) + StatChip(icon = Icons.Default.AttachMoney, text = String.format(Locale.US, "%.0f", totalValue), small = false) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(start = 18.dp, end = 12.dp, bottom = 16.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + if (garage.cabinets.isEmpty()) { + EmptyChildState("No cabinets in this garage") + } else { + garage.cabinets.forEach { cabinet -> + val itemsInCabinet = itemsInGarage.filter { it.cabinetId == cabinet.id } + CollapsibleCabinetSection(cabinet, itemsInCabinet) + } + } + } + } + } + } +} + +@Composable +private fun CollapsibleCabinetSection(cabinet: Cabinet, itemsInCabinet: List) { + var isExpanded by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + modifier = Modifier.animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + ) { + Column { + PreviewSectionHeader( + title = cabinet.name, + icon = Icons.Default.Kitchen, + isExpanded = isExpanded, + onToggleExpand = { isExpanded = !isExpanded } + ) { + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInCabinet.size}", small = true) + } + + AnimatedVisibility(visible = isExpanded) { + Column( + modifier = Modifier.padding(start = 18.dp, end = 12.dp, bottom = 14.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (cabinet.shelves.isEmpty()) { + EmptyChildState("No shelves in this cabinet") + } else { + cabinet.shelves.forEach { shelf -> + val itemsInShelf = itemsInCabinet.filter { it.shelfId == shelf.id } + CollapsibleShelfSection(shelf, itemsInShelf) + } + } + } + } + } + } +} + +@Composable +private fun CollapsibleShelfSection(shelf: Shelf, itemsInShelf: List) { + var isExpanded by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)), + color = MaterialTheme.colorScheme.background, + modifier = Modifier.animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + ) { + Column { + PreviewSectionHeader( + title = shelf.name, + icon = Icons.AutoMirrored.Filled.ViewList, + isExpanded = isExpanded, + onToggleExpand = { isExpanded = !isExpanded } + ) { + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInShelf.size}", small = true) + } + + AnimatedVisibility(visible = isExpanded) { + Column( + modifier = Modifier.padding(start = 18.dp, end = 12.dp, bottom = 14.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + val looseItems = itemsInShelf.filter { it.boxId == null } + if (shelf.boxes.isEmpty() && looseItems.isEmpty()) { + EmptyChildState("No items or boxes on this shelf") + } else { + shelf.boxes.forEach { box -> + val itemsInBox = itemsInShelf.filter { it.boxId == box.id } + CollapsibleBoxSection(box, itemsInBox) + } + looseItems.forEach { item -> + NeatLayoutItemRow(item) + } + } + } + } + } + } +} + +@Composable +private fun CollapsibleBoxSection(box: Box, itemsInBox: List) { + var isExpanded by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, Color(0xFF8B5CF6).copy(alpha = 0.2f)), + color = Color(0xFF8B5CF6).copy(alpha = 0.08f), + modifier = Modifier + .fillMaxWidth() + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = Color(0xFF8B5CF6).copy(alpha = 0.2f), + shape = RoundedCornerShape(10.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Inventory, + null, + modifier = Modifier.size(22.dp), + tint = Color(0xFF8B5CF6) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + box.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + "${itemsInBox.size} items", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = "Toggle", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AnimatedVisibility(visible = isExpanded) { + Column( + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (itemsInBox.isEmpty()) { + EmptyChildState("No items in this box") + } else { + itemsInBox.forEach { item -> + NeatLayoutItemRow(item) + } + } + } } } } } -// ====================================================================== -// SECTION COMPOSABLES -// ====================================================================== +// ======================================================================================== +// EDIT MODE COMPONENTS +// ======================================================================================== @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) { +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) } + var showAddDialog 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))) { + Card( + elevation = CardDefaults.cardElevation(3.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + ), + shape = RoundedCornerShape(18.dp), + modifier = Modifier.animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + ) { Column { SectionHeader( - title = garage.name, icon = Icons.Default.HomeWork, isExpanded = isExpanded, + title = garage.name, + icon = Icons.Default.HomeWork, + isExpanded = isExpanded, onToggleExpand = { isExpanded = !isExpanded }, - onAddClick = onAddCabinet, + onAddClick = { showAddDialog = true }, 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)}") + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInGarage.size}", small = false) + StatChip(icon = Icons.Default.AttachMoney, text = String.format(Locale.US, "%.0f", totalValue), small = false) } - AnimatedVisibility(visible = isExpanded) { - Column(modifier = Modifier.padding(start = 24.dp, end = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(start = 18.dp, end = 12.dp, bottom = 16.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { if (garage.cabinets.isEmpty()) { - EmptyChildState("No cabinets here. Tap '+ Add' to create one.") + EmptyChildState("No cabinets yet. Tap '+' to add.") } else { garage.cabinets.forEach { cabinet -> val itemsInCabinet = itemsInGarage.filter { it.cabinetId == cabinet.id } - CabinetSection(cabinet, itemsInCabinet, { onAddShelf(cabinet.id) }, onAddBox, onRenameLocation, onDeleteLocation) + CabinetSection( + cabinet, + itemsInCabinet, + { onAddShelf(cabinet.id) }, + onAddBox, + onRenameLocation, + onDeleteLocation + ) } } } } } } + + if (showAddDialog) { + MultiAddDialog( + title = "Add Cabinets", + onDismiss = { showAddDialog = false }, + onConfirm = { count -> + repeat(count) { onAddCabinet() } + } + ) + } } @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) { +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))) { + var showAddDialog by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + modifier = Modifier.animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + ) { Column { SectionHeader( - title = cabinet.name, icon = Icons.Default.Kitchen, isExpanded = isExpanded, + title = cabinet.name, + icon = Icons.Default.Kitchen, + isExpanded = isExpanded, onToggleExpand = { isExpanded = !isExpanded }, - onAddClick = onAddShelf, + onAddClick = { showAddDialog = true }, 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) + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInCabinet.size}", small = true) } - AnimatedVisibility(visible = isExpanded) { - Column(modifier = Modifier.padding(start = 24.dp, end = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(start = 18.dp, end = 12.dp, bottom = 14.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { if (cabinet.shelves.isEmpty()) { - EmptyChildState("No shelves in this cabinet.") + EmptyChildState("No shelves yet. Tap '+' to add.") } else { cabinet.shelves.forEach { shelf -> val itemsInShelf = itemsInCabinet.filter { it.shelfId == shelf.id } - ShelfSection(shelf, itemsInShelf, { onAddBox(shelf.id) }, onRenameLocation, onDeleteLocation) + ShelfSection( + shelf, + itemsInShelf, + { onAddBox(shelf.id) }, + onRenameLocation, + onDeleteLocation + ) } } } } } } + + if (showAddDialog) { + MultiAddDialog( + title = "Add Shelves", + onDismiss = { showAddDialog = false }, + onConfirm = { count -> + repeat(count) { onAddShelf() } + } + ) + } } @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) { +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))) { + var showAddDialog by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)), + color = MaterialTheme.colorScheme.background, + modifier = Modifier.animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + ) { Column { SectionHeader( - title = shelf.name, icon = Icons.AutoMirrored.Filled.ViewList, isExpanded = isExpanded, + title = shelf.name, + icon = Icons.AutoMirrored.Filled.ViewList, + isExpanded = isExpanded, onToggleExpand = { isExpanded = !isExpanded }, - onAddClick = onAddBox, + onAddClick = { showAddDialog = true }, 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) + StatChip(icon = Icons.Default.Inventory2, text = "${itemsInShelf.size}", small = true) } - AnimatedVisibility(visible = isExpanded) { - Column(modifier = Modifier.padding(start = 24.dp, end = 8.dp, bottom = 8.dp)) { + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(start = 18.dp, end = 12.dp, bottom = 14.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { if (shelf.boxes.isEmpty()) { - EmptyChildState("No boxes on this shelf.") + EmptyChildState("No boxes yet. Tap '+' to add.") } else { shelf.boxes.forEach { box -> val itemsInBox = itemsInShelf.filter { it.boxId == box.id } - BoxItem(box = box, itemCount = itemsInBox.size) + BoxItem( + box = box, + itemCount = itemsInBox.size, + onRename = { onRenameLocation(box.id, box.name, "box") }, + onDelete = { onDeleteLocation(box.id, box.name, "box") } + ) } } } } } } -} + if (showAddDialog) { + MultiAddDialog( + title = "Add Boxes", + onDismiss = { showAddDialog = false }, + onConfirm = { count -> + repeat(count) { onAddBox() } + } + ) + } +} @Composable -private fun SectionHeader(title: String, icon: ImageVector, isExpanded: Boolean, onToggleExpand: () -> Unit, onAddClick: () -> Unit, onRenameClick: () -> Unit, onDeleteClick: () -> Unit, statsContent: @Composable RowScope.() -> Unit) { +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)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggleExpand) + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(42.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + icon, + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + 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() } + Text( + text = title, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 6.dp) + ) { + statsContent() + } } - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onAddClick) { Icon(Icons.Default.Add, "+ Add") } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton( + onClick = onAddClick, + modifier = Modifier + .size(40.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape + ) + ) { + Icon( + Icons.Default.Add, + "Add", + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + 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() }) + IconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Default.MoreVert, + "More Options", + modifier = Modifier.size(22.dp) + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Default.Edit, null, modifier = Modifier.size(20.dp)) }, + onClick = { showMenu = false; onRenameClick() } + ) + DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + leadingIcon = { + Icon( + Icons.Default.Delete, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { showMenu = false; onDeleteClick() } + ) } } - Icon(imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, "Toggle Section") + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = "Toggle", + modifier = Modifier.size(26.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun PreviewSectionHeader( + title: String, + icon: ImageVector, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + statsContent: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggleExpand) + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(42.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + icon, + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 6.dp) + ) { + statsContent() + } } + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = "Toggle", + modifier = Modifier.size(26.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } @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) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Icon( + icon, + null, + modifier = Modifier.size(if (small) 16.dp else 18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(Modifier.width(6.dp)) + Text( + text, + style = if (small) MaterialTheme.typography.labelMedium else MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.SemiBold + ) + } } } @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) +private fun BoxItem( + box: Box, + itemCount: Int, + onRename: () -> Unit, + onDelete: () -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, Color(0xFF8B5CF6).copy(alpha = 0.2f)), + color = Color(0xFF8B5CF6).copy(alpha = 0.08f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = Color(0xFF8B5CF6).copy(alpha = 0.2f), + shape = RoundedCornerShape(10.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Inventory, + null, + modifier = Modifier.size(22.dp), + tint = Color(0xFF8B5CF6) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + box.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + "$itemCount items", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Box { + IconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.MoreVert, + "More", + modifier = Modifier.size(20.dp) + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Default.Edit, null, modifier = Modifier.size(20.dp)) }, + onClick = { showMenu = false; onRename() } + ) + DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + leadingIcon = { + Icon( + Icons.Default.Delete, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { showMenu = false; onDelete() } + ) + } + } + } } } @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)) + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(120.dp) + .background( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.tertiaryContainer + ) + ), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.LocationCity, + null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Spacer(Modifier.height(24.dp)) - Button(onClick = onAddGarage) { Icon(Icons.Default.Add, "Add Garage"); Spacer(Modifier.width(8.dp)); Text("Add Your First Garage") } + + Text( + "No Storage Locations Yet", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Text( + "Create your first garage to start organizing your inventory efficiently", + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(Modifier.height(32.dp)) + + Button( + onClick = onAddGarage, + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp), + elevation = ButtonDefaults.buttonElevation(4.dp) + ) { + Icon(Icons.Default.Add, "Add", modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(10.dp)) + Text("Create First Garage", style = MaterialTheme.typography.titleSmall) + } } } @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) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(12.dp) + ) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + 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 index 52574d6..fdcdb1b 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/MainAppScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/MainAppScreen.kt @@ -1,30 +1,53 @@ package com.samuel.inventorymanager.screens +import android.Manifest import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme 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.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.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.HelpOutline import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.CloudUpload 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.PhotoLibrary import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Sync import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -33,15 +56,19 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -49,8 +76,8 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,47 +85,87 @@ 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.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.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController import com.google.gson.Gson import com.samuel.inventorymanager.data.AppSettings +import com.samuel.inventorymanager.data.AppTheme +import com.samuel.inventorymanager.services.OCRService 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 +import java.util.UUID -// ======================================================================================== -// SCREEN NAVIGATION DEFINITIONS -// ======================================================================================== +// ViewModel Factory +class CreateItemViewModelFactory( + private val application: android.app.Application, + private val ocrService: OCRService +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(CreateItemViewModel::class.java)) { + return CreateItemViewModel(application, ocrService) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} -enum class Screen { - Dashboard, Items, Locations, Search, - Overview, Images, History, Settings, Sync, Help, - CreateItem +// Navigation Routes +sealed class AppScreen(val route: String) { + object Dashboard : AppScreen("dashboard") + object Locations : AppScreen("locations") + object Search : AppScreen("search") + object Overview : AppScreen("overview") + object Images : AppScreen("images") + object History : AppScreen("history") + object Settings : AppScreen("settings") + object Sync : AppScreen("sync") + object Help : AppScreen("help") + object CreateItem : AppScreen("create_item") + object ShareScreen : AppScreen("share") + + object Drive : AppScreen("google sync") // ADD THIS + // Image flow screens + object ImageEdit : AppScreen("image_edit") + object AIProcessing : AppScreen("ai_processing") + object AIResults : AppScreen("ai_results") } data class NavGridItem( val title: String, val icon: ImageVector, - val screen: Screen + val screen: AppScreen ) 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) + NavGridItem("Dashboard", Icons.Default.Dashboard, AppScreen.Dashboard), + NavGridItem("Create Item", Icons.Default.Inventory2, AppScreen.CreateItem), + NavGridItem("Locations", Icons.Default.LocationOn, AppScreen.Locations), + NavGridItem("Search", Icons.Default.Search, AppScreen.Search), + NavGridItem("Overview", Icons.AutoMirrored.Filled.List, AppScreen.Overview), + NavGridItem("Images", Icons.Default.Image, AppScreen.Images), + NavGridItem("Share Screen", Icons.Default.Share, AppScreen.ShareScreen), + NavGridItem("History", Icons.Default.History, AppScreen.History), + NavGridItem("Google Sync" , Icons.Default.CloudUpload, AppScreen.Drive), + NavGridItem("Settings", Icons.Default.Settings, AppScreen.Settings), + NavGridItem("Sync (COMING SOON)", Icons.Default.Sync, AppScreen.Sync), + NavGridItem("Help", Icons.AutoMirrored.Filled.HelpOutline, AppScreen.Help) ) sealed class DialogState { @@ -112,20 +179,39 @@ sealed class DialogState { object ClearHistory : DialogState() } -val LocalAppSettings = compositionLocalOf { AppSettings() } -val LocalOnSettingsChange = compositionLocalOf<(AppSettings) -> Unit> { {} } - -// ======================================================================================== -// MAIN APPLICATION SCREEN -// ======================================================================================== +@Composable +fun mapAppThemeToAppThemeType(appTheme: AppTheme): AppThemeType { + return when (appTheme) { + AppTheme.LIGHT -> AppThemeType.LIGHT + AppTheme.DARK -> AppThemeType.DARK + AppTheme.SYSTEM -> if (isSystemInDarkTheme()) AppThemeType.DARK else AppThemeType.LIGHT + AppTheme.DRACULA -> AppThemeType.DRACULA + AppTheme.VAMPIRE -> AppThemeType.VAMPIRE + AppTheme.OCEAN -> AppThemeType.OCEAN + AppTheme.FOREST -> AppThemeType.FOREST + AppTheme.SUNSET -> AppThemeType.SUNSET + AppTheme.CYBERPUNK -> AppThemeType.CYBERPUNK + AppTheme.NEON -> AppThemeType.NEON + AppTheme.CUSTOM -> AppThemeType.LIGHT + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainAppScreen() { val context = LocalContext.current + val navController = rememberNavController() + val scope = rememberCoroutineScope() + + // Factory for ViewModel injection + val ocrService = remember { OCRService(context) } + val createItemViewModel: CreateItemViewModel = viewModel( + factory = CreateItemViewModelFactory(context.applicationContext as android.app.Application, ocrService) + ) + + // Settings var appSettings by remember { mutableStateOf(AppSettings()) } - // Load settings on first launch LaunchedEffect(Unit) { appSettings = loadSettingsFromFile(context) } @@ -135,75 +221,183 @@ fun MainAppScreen() { 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 + appSettings.customTheme != null -> AppThemeType.LIGHT + else -> mapAppThemeToAppThemeType(appSettings.theme) } - val fontScale = appSettings.customTheme?.fontSizeScale ?: 1.0f + val fontScale = appSettings.customTheme?.fontSizeScale ?: appSettings.fontSize.scale - InventoryManagerTheme( - themeType = themeType, - fontScale = fontScale - ) { - var currentScreen by remember { mutableStateOf(Screen.Dashboard) } + InventoryManagerTheme(themeType = themeType, fontScale = fontScale) { + // Data State val garages = remember { mutableStateListOf() } val items = remember { mutableStateListOf() } val history = remember { mutableStateListOf() } var dialogState by remember { mutableStateOf(DialogState.Closed) } + var saveCounter by remember { mutableIntStateOf(0) } - val createItemViewModel: CreateItemViewModel = viewModel() + // Image flow state + var capturedImageUri by remember { mutableStateOf(null) } + var editedBitmap by remember { mutableStateOf(null) } + var aiAnalysisResult by remember { mutableStateOf(null) } + var showAddItemSheet by remember { mutableStateOf(false) } + + // Load data LaunchedEffect(Unit) { loadDataFromFile(context, garages, items, history) } - val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val scope = rememberCoroutineScope() + // Auto-save + LaunchedEffect(saveCounter) { + if (saveCounter > 0) { + saveDataToFile(context, AppData(garages.toList(), items.toList(), history.toList())) + } + } + + val triggerSave = { saveCounter++ } - val onSaveItem: (Item) -> Unit = { newItem -> - val index = items.indexOfFirst { it.id == newItem.id } - val newHistoryEntry: HistoryEntry + // Item operations + val onSaveItem: (Item) -> Unit = { item -> + val index = items.indexOfFirst { it.id == item.id } if (index != -1) { - newHistoryEntry = HistoryEntry( + items[index] = item + history.add(0, HistoryEntry( id = "hist_${System.currentTimeMillis()}", - itemId = newItem.id, - itemName = newItem.name, - action = HistoryAction.Updated, + itemId = item.id, + itemName = item.name, + actionType = "Updated", description = "Item details were modified." - ) - items[index] = newItem + )) } else { - newHistoryEntry = HistoryEntry( + items.add(item) + history.add(0, HistoryEntry( id = "hist_${System.currentTimeMillis()}", - itemId = newItem.id, - itemName = newItem.name, - action = HistoryAction.Added, + itemId = item.id, + itemName = item.name, + actionType = "Added", description = "A new item was created." - ) - items.add(newItem) + )) + } + triggerSave() + } + + val onUpdateItem: (Item) -> Unit = { updatedItem -> + val index = items.indexOfFirst { it.id == updatedItem.id } + if (index != -1) { + items[index] = updatedItem + history.add(0, HistoryEntry( + id = "hist_${System.currentTimeMillis()}", + itemId = updatedItem.id, + itemName = updatedItem.name, + actionType = "Updated", + description = "Item was updated." + )) + triggerSave() + } + } + + val onDeleteItem: (Item) -> Unit = { itemToDelete -> + items.remove(itemToDelete) + history.add(0, HistoryEntry( + id = "hist_${System.currentTimeMillis()}", + itemId = itemToDelete.id, + itemName = itemToDelete.name, + actionType = "Deleted", + description = "Item was permanently deleted." + )) + triggerSave() + } + val onDeleteHistoryEntry: (HistoryEntry) -> Unit = { entry -> + history.remove(entry) + triggerSave() + } + + + // Camera & Gallery Launchers + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + if (success && capturedImageUri != null) { + navController.navigate(AppScreen.ImageEdit.route) } - history.add(0, newHistoryEntry) - saveDataToFile(context, AppData(garages.toList(), items.toList(), history.toList())) } - val onClearHistory = { dialogState = DialogState.ClearHistory } + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + uri?.let { + capturedImageUri = it + navController.navigate(AppScreen.ImageEdit.route) + } + } + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + val uri = createImageUri(context) + capturedImageUri = uri + cameraLauncher.launch(uri) + } + } + + // Drawer state + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route + val currentAppData = AppData(garages = garages, items = items, history = history) + // --- CALLBACK FUNCTIONS FOR SETTINGS SCREEN --- + val onDataChange: (AppData) -> Unit = { importedData -> + // This runs when you import a file from settings + garages.clear() + items.clear() + history.clear() + + garages.addAll(importedData.garages) + items.addAll(importedData.items) + history.addAll(importedData.history) + + triggerSave() // Save the new imported data + } + + val onClearAllData: () -> Unit = { + // This runs when you click "Clear All Data" in settings + garages.clear() + items.clear() + history.clear() + + // Also delete the physical file + val file = File(context.filesDir, "app_data.json") + if (file.exists()) { + file.delete() + } + + triggerSave() // Save the empty state + } + + // Hide bottom bar on image flow screens + val hideBottomBar = currentRoute in listOf( + AppScreen.ImageEdit.route, + AppScreen.AIProcessing.route, + AppScreen.AIResults.route + ) + + // Dialogs HandleDialogs( dialogState = dialogState, garages = garages, - onClearHistory = { - history.clear() + onDismiss = { dialogState = DialogState.Closed - saveDataToFile(context, AppData(garages.toList(), items.toList(), history.toList())) + triggerSave() }, - onDismiss = { + onClearHistory = { + history.clear() dialogState = DialogState.Closed - saveDataToFile(context, AppData(garages.toList(), items.toList(), history.toList())) + triggerSave() } ) + // Main UI ModalNavigationDrawer( drawerState = drawerState, drawerContent = { @@ -222,9 +416,24 @@ fun MainAppScreen() { verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(navigationItems) { item -> - GridNavigationButton(item, currentScreen == item.screen) { - currentScreen = item.screen - scope.launch { drawerState.close() } + GridNavigationButton( + item = item, + isSelected = currentRoute == item.screen.route + ) { + // Auto-open camera when clicking "Items" + if (item.screen == AppScreen.CreateItem) { + scope.launch { drawerState.close() } + showAddItemSheet = true // This opens camera/gallery sheet + } else { + navController.navigate(item.screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + scope.launch { drawerState.close() } + } } } } @@ -234,106 +443,560 @@ fun MainAppScreen() { ) { Scaffold( topBar = { - TopAppBar( - title = { - Text( - currentScreen.name, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + if (!hideBottomBar) { + TopAppBar( + title = { + Text( + getScreenTitle(currentRoute), + 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 ) - }, - 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 } + if (!hideBottomBar) { + ModernBottomNavBar( + currentRoute = currentRoute, + onNavigate = { route -> + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + onAddItem = { showAddItemSheet = true } ) } } ) { 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) + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + NavHost( + navController = navController, + startDestination = AppScreen.Dashboard.route + ) { + composable(AppScreen.Dashboard.route) { + DashboardScreen(items, garages) + } + composable(AppScreen.ShareScreen.route) { + ShareScreen( + garages = garages, + items = items, + history = history, + onDataImported = { importedData -> + garages.clear() + items.clear() + history.clear() + + garages.addAll(importedData.garages) + items.addAll(importedData.items) + history.addAll(importedData.history) + + triggerSave() + + navController.popBackStack() + } + ) + } + + composable(AppScreen.Drive.route) { + GoogleSyncScreen( + garages = garages, + items = items, + history = history, + onDataRestored = { restoredData -> + garages.clear() + garages.addAll(restoredData.garages) + items.clear() + items.addAll(restoredData.items) + history.clear() + history.addAll(restoredData.history) + triggerSave() + } + ) + } + + composable(AppScreen.Locations.route) { + 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) + } + ) + } + + composable(AppScreen.CreateItem.route) { + CreateItemScreen( + items = items, + garages = garages, + onSaveItem = onSaveItem, + onUpdateItem = onUpdateItem, + viewModel = createItemViewModel, + appSettings = appSettings, + onDeleteItem = onDeleteItem, + onSettingsChange = onSettingsChange + ) + } + + composable(AppScreen.Overview.route) { + OverviewScreen(items, garages) + } + + composable(AppScreen.Search.route) { + SearchScreen( + items = items, + garages = garages, + onEditItem = { item -> + createItemViewModel.loadItemForEditing(item, garages) + navController.navigate(AppScreen.CreateItem.route) + }, + onDeleteItem = { item -> + onDeleteItem(item) + }, + onDuplicateItem = { item -> + val duplicatedItem = item.copy( + id = UUID.randomUUID().toString(), + name = "${item.name} (Copy)" + ) + onSaveItem(duplicatedItem) + }, + onShareItem = { item -> + shareItem(context, item, garages) + } + ) + } + + composable(AppScreen.Settings.route) { + SettingsScreen( + currentSettings = appSettings, + onSettingsChange = { newSettings -> appSettings = newSettings }, + + // Add these three required parameters + currentData = currentAppData, + onDataChange = onDataChange, + onClearAllData = onClearAllData + ) + } + + composable(AppScreen.Help.route) { + HelpScreen() + } + + composable(AppScreen.Images.route) { + ImagesScreen(items) { item -> + createItemViewModel.loadItemForEditing(item, garages) + navController.navigate(AppScreen.CreateItem.route) } - ) - 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) + + composable(AppScreen.History.route) { + HistoryScreen( + history = history, + items = items, + onItemClick = { targetItem -> + createItemViewModel.loadItemForEditing(targetItem, garages) + navController.navigate(AppScreen.CreateItem.route) + }, + onClearHistory = { + dialogState = DialogState.ClearHistory + }, + onDeleteHistoryEntry = onDeleteHistoryEntry + ) + } + + + composable(AppScreen.Sync.route) { + PlaceholderScreen("Sync") + } + + // Image Edit Screen + composable(AppScreen.ImageEdit.route) { + capturedImageUri?.let { uri -> + ImageEditScreen( + imageUri = uri, + onNext = { bitmap, choice -> + editedBitmap = bitmap + + when (choice) { + ProcessingChoice.MANUAL -> { + val savedUri = saveBitmapToUri(context, bitmap) + createItemViewModel.clearFormForNewItem() + if(!createItemViewModel.imageUris.contains(savedUri)) { + createItemViewModel.imageUris.add(savedUri) + } + + navController.navigate(AppScreen.CreateItem.route) { + popUpTo(AppScreen.Dashboard.route) + } + } + ProcessingChoice.AI_OCR -> { + navController.navigate(AppScreen.AIProcessing.route) + } + } + }, + onCancel = { + capturedImageUri = null + editedBitmap = null + navController.popBackStack() + } + ) + } + } + + // AI Processing Screen + composable(AppScreen.AIProcessing.route) { + editedBitmap?.let { bitmap -> + AIProcessingScreen( + bitmap = bitmap, + onComplete = { result -> + aiAnalysisResult = result + navController.navigate(AppScreen.AIResults.route) + }, + onError = { _ -> + scope.launch { + navController.popBackStack() + } + } + ) + } + } + + // AI Results Screen + composable(AppScreen.AIResults.route) { + editedBitmap?.let { bitmap -> + aiAnalysisResult?.let { result -> + AIResultsScreen( + bitmap = bitmap, + result = result, + onBackToEdit = { + navController.navigate(AppScreen.ImageEdit.route) { + popUpTo(AppScreen.ImageEdit.route) { inclusive = true } + } + }, + onSaveAndContinue = { finalResult -> + val savedUri = saveBitmapToUri(context, bitmap) + createItemViewModel.clearFormForNewItem() + + // Prefill from AI result using ViewModel properties + finalResult.itemName?.let { createItemViewModel.itemName = it } + finalResult.description?.let { createItemViewModel.description = it } + finalResult.modelNumber?.let { createItemViewModel.modelNumber = it } + finalResult.dimensions?.let { createItemViewModel.dimensions = it } + finalResult.estimatedPrice?.let { price -> + createItemViewModel.minPrice = price.toString() + createItemViewModel.maxPrice = (price * 1.2).toString() + } + + if(!createItemViewModel.imageUris.contains(savedUri)) { + createItemViewModel.imageUris.add(savedUri) + } + + navController.navigate(AppScreen.CreateItem.route) { + popUpTo(AppScreen.Dashboard.route) + } + + capturedImageUri = null + editedBitmap = null + aiAnalysisResult = null + } + ) + } + } + } + } + } + } + } + + // Add Item Bottom Sheet + if (showAddItemSheet) { + AddItemBottomSheet( + onDismiss = { showAddItemSheet = false }, + onCameraClick = { + showAddItemSheet = false + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) { + val uri = createImageUri(context) + capturedImageUri = uri + cameraLauncher.launch(uri) + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) } + }, + onGalleryClick = { + showAddItemSheet = false + galleryLauncher.launch("image/*") + } + ) + } + } +} + +// Modern Bottom Navigation Bar +@Composable +fun ModernBottomNavBar( + currentRoute: String?, + onNavigate: (String) -> Unit, + onAddItem: () -> Unit +) { + Surface( + shadowElevation = 8.dp, + tonalElevation = 3.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .background(MaterialTheme.colorScheme.surface), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + BottomNavItem( + icon = Icons.Default.LocationOn, + label = "Locations", + selected = currentRoute == AppScreen.Locations.route, + onClick = { onNavigate(AppScreen.Locations.route) } + ) + + Box( + modifier = Modifier + .size(64.dp) + .offset(y = (-8).dp) + ) { + FloatingActionButton( + onClick = onAddItem, + containerColor = MaterialTheme.colorScheme.primary, + elevation = FloatingActionButtonDefaults.elevation(8.dp) + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add Item", + modifier = Modifier.size(32.dp) + ) } } + + BottomNavItem( + icon = Icons.AutoMirrored.Filled.List, + label = "Overview", + selected = currentRoute == AppScreen.Overview.route, + onClick = { onNavigate(AppScreen.Overview.route) } + ) } } } -// ======================================================================================== -// HELPER COMPOSABLES -// ======================================================================================== +@Composable +fun BottomNavItem( + icon: ImageVector, + label: String, + selected: Boolean, + onClick: () -> Unit +) { + val color by animateColorAsState( + targetValue = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + label = "nav_color" + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .clickable(onClick = onClick) + .padding(8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = color, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.height(4.dp)) + Text( + text = label, + fontSize = 12.sp, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = color + ) + } +} + +// Add Item Bottom Sheet +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddItemBottomSheet( + onDismiss: () -> Unit, + onCameraClick: () -> Unit, + onGalleryClick: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = Color(0xFF1E293B), + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.White.copy(alpha = 0.3f), + modifier = Modifier + .width(40.dp) + .height(4.dp) + ) {} + + Spacer(Modifier.height(16.dp)) + + Text( + "📸 Add New Item", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + + Text( + "Choose how to add your item", + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp, + modifier = Modifier.padding(top = 4.dp) + ) + } + + HorizontalDivider( + color = Color.White.copy(alpha = 0.1f), + modifier = Modifier.padding(vertical = 8.dp) + ) + + AddItemOptionCard( + icon = Icons.Default.CameraAlt, + title = "Take Photo", + description = "Use your camera to capture the item", + color = Color(0xFF8B5CF6), + onClick = onCameraClick + ) + + AddItemOptionCard( + icon = Icons.Default.PhotoLibrary, + title = "Choose from Gallery", + description = "Select an existing photo", + color = Color(0xFF10B981), + onClick = onGalleryClick + ) + + OutlinedButton( + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(2.dp, Color.White.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ) + ) { + Text("Cancel", fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +fun AddItemOptionCard( + icon: ImageVector, + title: String, + description: String, + color: Color, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.15f) + ), + border = BorderStroke(2.dp, color.copy(alpha = 0.3f)) + ) { + Row( + modifier = Modifier.padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = CircleShape, + color = color, + modifier = Modifier.size(56.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + Text( + title, + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Text( + description, + color = Color.White.copy(alpha = 0.7f), + fontSize = 13.sp + ) + } + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = Color.White.copy(alpha = 0.5f), + modifier = Modifier.size(24.dp) + ) + } + } +} @Composable private fun GridNavigationButton(item: NavGridItem, isSelected: Boolean, onClick: () -> Unit) { @@ -398,256 +1061,393 @@ fun PlaceholderScreen(screenName: String) { } } -@OptIn(ExperimentalMaterial3Api::class) +// Helper function to share an item +fun shareItem(context: Context, item: Item, garages: List) { + val locationPath = buildLocationPath(item, garages) + + val shareText = buildString { + appendLine("📦 ${item.name}") + item.modelNumber?.let { appendLine("Model: $it") } + appendLine() + item.description?.let { + appendLine("Description:") + appendLine(it) + appendLine() + } + appendLine("📍 Location: $locationPath") + appendLine("Quantity: ${item.quantity}") + appendLine("Condition: ${item.condition}") + item.dimensions?.let { appendLine("Dimensions: $it") } + + if (item.minPrice != null || item.maxPrice != null) { + appendLine() + append("💰 Price: ") + when { + item.minPrice != null && item.maxPrice != null -> + appendLine("$${item.minPrice} - $${item.maxPrice}") + item.minPrice != null -> appendLine("$${item.minPrice}") + else -> appendLine("Up to $${item.maxPrice}") + } + } + } + + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, shareText) + putExtra(Intent.EXTRA_TITLE, "Item: ${item.name}") + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, "Share ${item.name}") + context.startActivity(shareIntent) +} + +// Helper function for location path +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" } +} + +private fun getScreenTitle(route: String?): String { + return when (route) { + AppScreen.Dashboard.route -> "Dashboard" + AppScreen.Locations.route -> "Locations" + AppScreen.Search.route -> "Search" + AppScreen.Overview.route -> "Overview" + AppScreen.Images.route -> "Images" + AppScreen.History.route -> "History" + AppScreen.Settings.route -> "Settings" + AppScreen.Sync.route -> "Sync" + AppScreen.Help.route -> "Help" + AppScreen.CreateItem.route -> "Create Item" + AppScreen.ImageEdit.route -> "Edit Image" + AppScreen.AIProcessing.route -> "AI Processing" + AppScreen.AIResults.route -> "AI Results" + else -> "Inventory Manager" + } +} + +fun createImageUri(context: Context): Uri { + val imageFile = File( + context.cacheDir, + "captured_image_${System.currentTimeMillis()}.jpg" + ) + return FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + imageFile + ) +} + +fun saveBitmapToUri(context: Context, bitmap: Bitmap): Uri { + val file = File( + context.cacheDir, + "edited_image_${System.currentTimeMillis()}.jpg" + ) + file.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + return FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file + ) +} + @Composable private fun HandleDialogs( dialogState: DialogState, garages: MutableList, - onClearHistory: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + onClearHistory: () -> 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()) + when (dialogState) { + is DialogState.AddGarage -> { + BasicInputDialog(title = "Add Garage", onConfirm = { name -> + garages.add( + Garage( + id = "garage_${System.currentTimeMillis()}", + name = name, + cabinets = mutableListOf() + ) ) - } + onDismiss() + }, onDismiss = onDismiss) } - 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) - } + is DialogState.AddCabinet -> { + BasicInputDialog(title = "Add Cabinet", onConfirm = { name -> + val garageIndex = garages.indexOfFirst { it.id == dialogState.garageId } + if (garageIndex != -1) { + val garage = garages[garageIndex] + val updatedCabinets = garage.cabinets.toMutableList() + updatedCabinets.add( + Cabinet( + id = "cabinet_${System.currentTimeMillis()}", + name = name, + shelves = mutableListOf() + ) ) - break + garages[garageIndex] = garage.copy(cabinets = updatedCabinets) } - } + onDismiss() + }, onDismiss = onDismiss) } - 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) - } - ) - } + is DialogState.AddShelf -> { + BasicInputDialog(title = "Add Shelf", onConfirm = { name -> + garages.forEachIndexed { garageIndex, garage -> + val cabinetIndex = garage.cabinets.indexOfFirst { it.id == dialogState.cabinetId } + if (cabinetIndex != -1) { + val cabinet = garage.cabinets[cabinetIndex] + val updatedShelves = cabinet.shelves.toMutableList() + updatedShelves.add( + Shelf( + id = "shelf_${System.currentTimeMillis()}", + name = name, + boxes = mutableListOf() + ) + ) + val updatedCabinets = garage.cabinets.toMutableList() + updatedCabinets[cabinetIndex] = cabinet.copy(shelves = updatedShelves) + garages[garageIndex] = garage.copy(cabinets = updatedCabinets) + } + } + onDismiss() + }, onDismiss = onDismiss) + } + is DialogState.AddBox -> { + BasicInputDialog(title = "Add Box", onConfirm = { name -> + garages.forEachIndexed { garageIndex, garage -> + garage.cabinets.forEachIndexed { cabinetIndex, cabinet -> + val shelfIndex = cabinet.shelves.indexOfFirst { it.id == dialogState.shelfId } + if (shelfIndex != -1) { + val shelf = cabinet.shelves[shelfIndex] + val updatedBoxes = shelf.boxes.toMutableList() + updatedBoxes.add( + Box( + id = "box_${System.currentTimeMillis()}", + name = name + ) ) - return@loop + val updatedShelves = cabinet.shelves.toMutableList() + updatedShelves[shelfIndex] = shelf.copy(boxes = updatedBoxes) + val updatedCabinets = garage.cabinets.toMutableList() + updatedCabinets[cabinetIndex] = cabinet.copy(shelves = updatedShelves) + garages[garageIndex] = garage.copy(cabinets = updatedCabinets) } } } - } + onDismiss() + }, onDismiss = onDismiss) } is DialogState.RenameLocation -> { - RenameDialog(state, onDismiss) { /* Rename logic */ } + BasicInputDialog( + title = "Rename ${dialogState.type}", + initialValue = dialogState.oldName, + onConfirm = { newName -> + when (dialogState.type) { + "Garage" -> { + val index = garages.indexOfFirst { it.id == dialogState.id } + if (index != -1) { + garages[index] = garages[index].copy(name = newName) + } + } + "Cabinet" -> { + garages.forEachIndexed { garageIndex, garage -> + val cabinetIndex = garage.cabinets.indexOfFirst { it.id == dialogState.id } + if (cabinetIndex != -1) { + val updatedCabinets = garage.cabinets.toMutableList() + updatedCabinets[cabinetIndex] = updatedCabinets[cabinetIndex].copy(name = newName) + garages[garageIndex] = garage.copy(cabinets = updatedCabinets) + } + } + } + "Shelf" -> { + garages.forEachIndexed { garageIndex, garage -> + garage.cabinets.forEachIndexed { cabinetIndex, cabinet -> + val shelfIndex = cabinet.shelves.indexOfFirst { it.id == dialogState.id } + if (shelfIndex != -1) { + val updatedShelves = cabinet.shelves.toMutableList() + updatedShelves[shelfIndex] = updatedShelves[shelfIndex].copy(name = newName) + val updatedCabinets = garage.cabinets.toMutableList() + updatedCabinets[cabinetIndex] = cabinet.copy(shelves = updatedShelves) + garages[garageIndex] = garage.copy(cabinets = updatedCabinets) + } + } + } + } + "Box" -> { + garages.forEachIndexed { garageIndex, garage -> + garage.cabinets.forEachIndexed { cabinetIndex, cabinet -> + cabinet.shelves.forEachIndexed { shelfIndex, shelf -> + val boxIndex = shelf.boxes.indexOfFirst { it.id == dialogState.id } + if (boxIndex != -1) { + val updatedBoxes = shelf.boxes.toMutableList() + updatedBoxes[boxIndex] = updatedBoxes[boxIndex].copy(name = newName) + val updatedShelves = cabinet.shelves.toMutableList() + updatedShelves[shelfIndex] = shelf.copy(boxes = updatedBoxes) + val updatedCabinets = garage.cabinets.toMutableList() + updatedCabinets[cabinetIndex] = cabinet.copy(shelves = updatedShelves) + garages[garageIndex] = garage.copy(cabinets = updatedCabinets) + } + } + } + } + } + } + onDismiss() + }, + onDismiss = onDismiss + ) } 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.") }, + title = { Text("Delete ${dialogState.type}?") }, + text = { Text("Are you sure you want to delete '${dialogState.name}'? This cannot be undone.") }, confirmButton = { - Button( - onClick = onClearHistory, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - ) { - Text("Clear All") - } + Button(onClick = { + when (dialogState.type) { + "Garage" -> { + garages.removeIf { it.id == dialogState.id } + } + "Cabinet" -> { + garages.forEachIndexed { garageIndex, garage -> + val updatedCabinets = garage.cabinets.filter { it.id != dialogState.id } + if (updatedCabinets.size != garage.cabinets.size) { + garages[garageIndex] = garage.copy(cabinets = updatedCabinets as MutableList) + } + } + } + "Shelf" -> { + garages.forEachIndexed { garageIndex, garage -> + garage.cabinets.forEachIndexed { cabinetIndex, cabinet -> + val updatedShelves = cabinet.shelves.filter { it.id != dialogState.id } + if (updatedShelves.size != cabinet.shelves.size) { + val updatedCabinets = garage.cabinets.toMutableList() + updatedCabinets[cabinetIndex] = cabinet.copy(shelves = updatedShelves as MutableList) + garages[garageIndex] = garage.copy(cabinets = updatedCabinets) + } + } + } + } + "Box" -> { + garages.forEachIndexed { garageIndex, garage -> + garage.cabinets.forEachIndexed { cabinetIndex, cabinet -> + cabinet.shelves.forEachIndexed { shelfIndex, shelf -> + val updatedBoxes = shelf.boxes.filter { it.id != dialogState.id } + if (updatedBoxes.size != shelf.boxes.size) { + val updatedShelves = cabinet.shelves.toMutableList() + updatedShelves[shelfIndex] = shelf.copy(boxes = updatedBoxes as MutableList) + val updatedCabinets = garage.cabinets.toMutableList() + updatedCabinets[cabinetIndex] = cabinet.copy(shelves = updatedShelves) + garages[garageIndex] = garage.copy(cabinets = updatedCabinets) + } + } + } + } + } + } + onDismiss() + }) { Text("Delete") } }, dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } + 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") } + is DialogState.ClearHistory -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Clear History") }, + text = { Text("Clear all history logs?") }, + confirmButton = { Button(onClick = onClearHistory) { Text("Clear") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) - }, - confirmButton = { - Button( - onClick = { - if (text.isNotBlank()) onConfirm(text) - onDismiss() - } - ) { - Text("Rename") - } - }, - dismissButton = { - TextButton(onDismiss) { - Text("Cancel") - } } - ) + DialogState.Closed -> { /* No dialog */ } + } } @Composable -private fun DeleteConfirmDialog( - state: DialogState.DeleteLocation, - onDismiss: () -> Unit, - onConfirm: () -> Unit +fun BasicInputDialog( + title: String, + initialValue: String = "", + onConfirm: (String) -> Unit, + onDismiss: () -> Unit ) { + var text by remember { mutableStateOf(initialValue) } 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") - } - } + title = { Text(title) }, + text = { OutlinedTextField(value = text, onValueChange = { text = it }) }, + confirmButton = { Button(onClick = { onConfirm(text) }) { Text("OK") } }, + dismissButton = { TextButton(onClick = 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) - } + val file = File(context.filesDir, "app_data.json") + if (file.exists()) { + val json = file.readText() + val data = Gson().fromJson(json, AppData::class.java) + garages.clear() + garages.addAll(data.garages) + items.clear() + items.addAll(data.items) + history.clear() + history.addAll(data.history) } - } catch (_: Exception) { - // Failed to load data, starting fresh + } catch (e: Exception) { + e.printStackTrace() } } -private fun saveSettingsToFile(context: Context, settings: AppSettings) { +private fun saveDataToFile(context: Context, data: AppData) { try { - context.openFileOutput("app_settings.json", Context.MODE_PRIVATE).use { - it.write(Gson().toJson(settings).toByteArray()) - } + val file = File(context.filesDir, "app_data.json") + val json = Gson().toJson(data) + file.writeText(json) } 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) { + return try { + val file = File(context.filesDir, "settings.json") + if (file.exists()) { + val reader = InputStreamReader(file.inputStream()) + Gson().fromJson(reader, AppSettings::class.java) + } else { AppSettings() } - } else { + } catch (e: Exception) { + e.printStackTrace() AppSettings() } +} + +private fun saveSettingsToFile(context: Context, settings: AppSettings) { + try { + val file = File(context.filesDir, "settings.json") + val json = Gson().toJson(settings) + file.writeText(json) + } catch (e: Exception) { + e.printStackTrace() + } } \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/OnboardingScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/OnboardingScreen.kt new file mode 100644 index 0000000..b1c51b7 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/OnboardingScreen.kt @@ -0,0 +1,493 @@ +package com.samuel.inventorymanager.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.Inventory +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +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.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.Brush +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.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.HorizontalPagerIndicator +import com.google.accompanist.pager.rememberPagerState +import kotlinx.coroutines.delay + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun OnboardingScreen( + onGetStarted: () -> Unit, + onSignInWithGoogle: () -> Unit +) { + var currentPage by remember { mutableStateOf(0) } + val pagerState = rememberPagerState() + val scope = rememberCoroutineScope() + + // Auto-advance pages + LaunchedEffect(Unit) { + while (true) { + delay(5000) // 5 seconds per page + val nextPage = (pagerState.currentPage + 1) % 5 + pagerState.animateScrollToPage(nextPage) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF1A237E), + Color(0xFF0D47A1), + Color(0xFF01579B) + ) + ) + ) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Top App Logo & Title + AnimatedAppHeader() + + // Horizontal Pager for feature showcase + HorizontalPager( + count = 5, + state = pagerState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { page -> + OnboardingPage( + page = when (page) { + 0 -> OnboardingPageData( + icon = Icons.Default.Inventory, + title = "Welcome to Android Inventory Pro", + description = "The ultimate solution for organizing everything you own. From your garage tools to your kitchen supplies, never lose track of anything again!", + emoji = "📦" + ) + 1 -> OnboardingPageData( + icon = Icons.Default.LocationOn, + title = "Smart Location Hierarchy", + description = "Create Garages → Cabinets → Shelves → Boxes. Know exactly where every item is stored with our intuitive 4-level organization system.", + emoji = "🏠" + ) + 2 -> OnboardingPageData( + icon = Icons.Default.CameraAlt, + title = "AI-Powered Recognition", + description = "Take a photo and let our AI do the work! Automatically detect item names, model numbers, and descriptions. Save hours of manual data entry.", + emoji = "🤖" + ) + 3 -> OnboardingPageData( + icon = Icons.Default.Search, + title = "Instant Search & Filters", + description = "Find any item in seconds with powerful search. Filter by location, condition, tags, and more. Your entire inventory at your fingertips!", + emoji = "🔍" + ) + else -> OnboardingPageData( + icon = Icons.Default.Cloud, + title = "Secure Cloud Backup", + description = "Never lose your data! Sign in with Google to automatically backup your inventory to Google Drive. Sync across all your devices seamlessly.", + emoji = "☁️" + ) + } + ) + } + + // Page Indicators + HorizontalPagerIndicator( + pagerState = pagerState, + modifier = Modifier.padding(16.dp), + activeColor = Color.White, + inactiveColor = Color.White.copy(alpha = 0.3f) + ) + + // Bottom Action Section + BottomActionSection( + onSignInWithGoogle = onSignInWithGoogle, + onContinueWithoutSignIn = onGetStarted + ) + } + } +} + +@Composable +private fun AnimatedAppHeader() { + val infiniteTransition = rememberInfiniteTransition(label = "header") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "scale" + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // App Icon Placeholder (you can replace with actual drawable) + Surface( + modifier = Modifier + .size(100.dp) + .scale(scale), + shape = RoundedCornerShape(24.dp), + color = Color.White.copy(alpha = 0.2f) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + "📦", + fontSize = 48.sp + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Text( + "Android Inventory Pro", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center + ) + + Text( + "Organize Everything, Find Anything", + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + } +} + +data class OnboardingPageData( + val icon: ImageVector, + val title: String, + val description: String, + val emoji: String +) + +@Composable +private fun OnboardingPage(page: OnboardingPageData) { + var visible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(800)) + slideInVertically(tween(800)) { it / 2 } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Large Emoji + Text( + page.emoji, + fontSize = 80.sp, + modifier = Modifier.padding(bottom = 24.dp) + ) + + // Icon + Surface( + modifier = Modifier + .size(80.dp) + .padding(bottom = 24.dp), + shape = CircleShape, + color = Color.White.copy(alpha = 0.2f) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + page.icon, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = Color.White + ) + } + } + + // Title + Text( + page.title, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Description + Text( + page.description, + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.9f), + textAlign = TextAlign.Center, + lineHeight = 24.sp + ) + } + } +} + +@Composable +private fun BottomActionSection( + onSignInWithGoogle: () -> Unit, + onContinueWithoutSignIn: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Color.White.copy(alpha = 0.1f), + RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp) + ) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Sign in with Google Button + Button( + onClick = onSignInWithGoogle, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFF1A237E) + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + // Google Icon (simplified) + Surface( + modifier = Modifier.size(24.dp), + shape = CircleShape, + color = Color.Transparent + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("G", fontSize = 18.sp, fontWeight = FontWeight.Bold) + } + } + Spacer(Modifier.width(12.dp)) + Text( + "Sign in with Google", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + + // Continue without sign in + TextButton( + onClick = onContinueWithoutSignIn, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Continue without signing in", + color = Color.White, + fontSize = 14.sp + ) + } + + // Benefits text + Text( + "Sign in to enable cloud backup and sync", + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + } +} + +// ========================================== +// Feature Highlights Composable (Alternative detailed view) +// ========================================== + +@Composable +fun FeatureHighlightsSection() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + "Key Features", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(bottom = 8.dp) + ) + + FeatureCard( + icon = Icons.Default.Inventory2, + title = "4-Level Organization", + description = "Garage → Cabinet → Shelf → Box hierarchy" + ) + + FeatureCard( + icon = Icons.Default.Psychology, + title = "AI Recognition", + description = "Auto-detect items from photos with ML Kit" + ) + + FeatureCard( + icon = Icons.Default.QrCode, + title = "Barcode & OCR", + description = "Scan barcodes and extract text from images" + ) + + FeatureCard( + icon = Icons.Default.Backup, + title = "Google Drive Backup", + description = "Automatic cloud backup with your Google account" + ) + + FeatureCard( + icon = Icons.Default.Dashboard, + title = "Overview Dashboard", + description = "Complete spreadsheet view of all items" + ) + + FeatureCard( + icon = Icons.Default.FilterAlt, + title = "Advanced Filters", + description = "Filter by location, condition, tags, and more" + ) + } +} + +@Composable +private fun FeatureCard( + icon: ImageVector, + title: String, + description: String +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White.copy(alpha = 0.15f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(48.dp), + shape = CircleShape, + color = Color.White.copy(alpha = 0.2f) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(Modifier.width(16.dp)) + + Column { + Text( + title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Text( + description, + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.8f) + ) + } + } + } +} \ 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 index 2037b9c..eacded6 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/OverviewScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/OverviewScreen.kt @@ -1,11 +1,5 @@ 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 @@ -21,31 +15,30 @@ 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.filled.ArrowDropDown +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward 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.HomeWork 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.Sort 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 @@ -54,8 +47,6 @@ 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 @@ -64,9 +55,6 @@ 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 @@ -75,9 +63,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -// ====================================================================== -// MAIN OVERVIEW SCREEN - REDESIGNED -// ====================================================================== +enum class SortType { + NAME_ASC, NAME_DESC, PRICE_ASC, PRICE_DESC, DATE_NEW, DATE_OLD +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -86,28 +74,43 @@ fun OverviewScreen( garages: List ) { var searchQuery by remember { mutableStateOf("") } - var selectedLocation by remember { mutableStateOf("All Locations") } + var selectedGarageId by remember { mutableStateOf(null) } 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) } + var sortType by remember { mutableStateOf(SortType.NAME_ASC) } + var showSortMenu by remember { mutableStateOf(false) } + var selectedItemForDetail by remember { mutableStateOf(null) } + + // Filter by garage if selected + val garageFilteredItems = if (selectedGarageId != null) { + items.filter { it.garageId == selectedGarageId } + } else { + items + } - val filteredItems = remember(searchQuery, selectedLocation, selectedSize, selectedCondition, items) { - items.filter { item -> + // Apply other filters + val filteredItems = remember(searchQuery, garageFilteredItems, selectedSize, selectedCondition, sortType) { + var result = garageFilteredItems.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 + matchesSearch && matchesSize && matchesCondition + } + + // Apply sorting + when (sortType) { + SortType.NAME_ASC -> result.sortedBy { it.name.lowercase() } + SortType.NAME_DESC -> result.sortedByDescending { it.name.lowercase() } + SortType.PRICE_ASC -> result.sortedBy { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 } + SortType.PRICE_DESC -> result.sortedByDescending { ((it.minPrice ?: 0.0) + (it.maxPrice ?: 0.0)) / 2 } + SortType.DATE_NEW -> result.reversed() // Newest first (assuming items list is chronological) + SortType.DATE_OLD -> result // Oldest first } } @@ -120,6 +123,16 @@ fun OverviewScreen( val conditions = items.map { it.condition }.distinct().sorted() + // Show item detail PDF if selected + if (selectedItemForDetail != null) { + ItemDetailPDFScreen( + item = selectedItemForDetail!!, + garage = garages.find { it.id == selectedItemForDetail!!.garageId }, + onBackClick = { selectedItemForDetail = null } + ) + return + } + LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), @@ -129,7 +142,7 @@ fun OverviewScreen( item { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 4.dp) + modifier = Modifier.padding(vertical = 8.dp) ) { Icon( Icons.Default.List, @@ -144,9 +157,6 @@ fun OverviewScreen( 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) } @@ -159,7 +169,7 @@ fun OverviewScreen( value = searchQuery, onValueChange = { searchQuery = it }, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search by name or model number...") }, + placeholder = { Text("Search items...", fontSize = 14.sp) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, trailingIcon = { if (searchQuery.isNotEmpty()) { @@ -168,195 +178,258 @@ fun OverviewScreen( } } }, - shape = RoundedCornerShape(16.dp), - singleLine = true + shape = RoundedCornerShape(12.dp), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium ) } // Filter Row item { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), + Column( + verticalArrangement = Arrangement.spacedBy(8.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) + Text( + "Filters", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + FilterDropdownCompact( + 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) - ) + FilterDropdownCompact( + 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 + // Sort Button item { - ExpandableSection( - title = "Key Statistics", - subtitle = "${filteredItems.size} items", - icon = Icons.Default.TrendingUp, - isExpanded = expandedStats, - onToggle = { expandedStats = !expandedStats } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - 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) + Text( + "Sort by:", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Box { + OutlinedButton( + onClick = { showSortMenu = true }, + shape = RoundedCornerShape(8.dp) + ) { + Icon(Icons.Default.Sort, null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text( + when (sortType) { + SortType.NAME_ASC -> "Name (A-Z)" + SortType.NAME_DESC -> "Name (Z-A)" + SortType.PRICE_ASC -> "Price (Low-High)" + SortType.PRICE_DESC -> "Price (High-Low)" + SortType.DATE_NEW -> "Newest First" + SortType.DATE_OLD -> "Oldest First" + }, + fontSize = 12.sp ) } - 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) + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + DropdownMenuItem( + text = { Text("Name (A-Z)") }, + onClick = { sortType = SortType.NAME_ASC; showSortMenu = false }, + leadingIcon = { Icon(Icons.Default.ArrowUpward, null) } ) - ModernStatCard( - title = "Avg Value", - value = "$${String.format("%.0f", avgItemValue)}", - icon = Icons.Default.TrendingUp, - color = Color(0xFFB45309), - modifier = Modifier.weight(1f) + DropdownMenuItem( + text = { Text("Name (Z-A)") }, + onClick = { sortType = SortType.NAME_DESC; showSortMenu = false }, + leadingIcon = { Icon(Icons.Default.ArrowDownward, null) } ) - } - 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) + DropdownMenuItem( + text = { Text("Price (Low-High)") }, + onClick = { sortType = SortType.PRICE_ASC; showSortMenu = false }, + leadingIcon = { Icon(Icons.Default.ArrowUpward, null) } + ) + DropdownMenuItem( + text = { Text("Price (High-Low)") }, + onClick = { sortType = SortType.PRICE_DESC; showSortMenu = false }, + leadingIcon = { Icon(Icons.Default.ArrowDownward, null) } + ) + DropdownMenuItem( + text = { Text("Newest First") }, + onClick = { sortType = SortType.DATE_NEW; showSortMenu = false } ) - ModernStatCard( - title = "Locations Used", - value = "$locationsUsed/${garages.size}", - icon = Icons.Default.LocationCity, - color = Color(0xFFBE185D), - modifier = Modifier.weight(1f) + DropdownMenuItem( + text = { Text("Oldest First") }, + onClick = { sortType = SortType.DATE_OLD; showSortMenu = false } ) } } } } - - // Advanced Analytics Section + // Key Statistics 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) } - ) - } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Key Statistics", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) - Spacer(Modifier.height(20.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + CompactStatCard( + title = "Unique Items", + value = totalItemsCount.toString(), + icon = Icons.Default.Inventory, + color = Color(0xFF6200EE), + modifier = Modifier.weight(1f) + ) + CompactStatCard( + title = "Total Qty", + value = totalQuantity.toString(), + icon = Icons.Default.Numbers, + color = Color(0xFF03DAC5), + modifier = Modifier.weight(1f) + ) + } - 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" - ) - } + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + CompactStatCard( + title = "Total Value", + value = "$${String.format("%.0f", totalValue)}", + icon = Icons.Default.AttachMoney, + color = Color(0xFF047857), + modifier = Modifier.weight(1f) + ) + CompactStatCard( + 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), + modifier = Modifier.fillMaxWidth() + ) { + CompactStatCard( + title = "Total Weight", + value = "${String.format("%.1f", totalWeight)} lbs", + icon = Icons.Default.Scale, + color = Color(0xFF0369A1), + modifier = Modifier.weight(1f) + ) + CompactStatCard( + title = "Locations", + value = "$locationsUsed/${garages.size}", + icon = Icons.Default.LocationOn, + color = Color(0xFFBE185D), + modifier = Modifier.weight(1f) + ) } } } - // Item List + // Your Garages Section item { Text( - "Item List (${filteredItems.size})", + "Your Garages", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 8.dp) ) } - items(filteredItems) { item -> - ModernItemCard(item = item, garages = garages) + item { + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(garages) { garage -> + GarageOverviewCard( + garageName = garage.name, + itemCount = items.count { it.garageId == garage.id }, + isSelected = selectedGarageId == garage.id, + onClick = { + selectedGarageId = if (selectedGarageId == garage.id) null else garage.id + } + ) + } + } + } + + // Item List Header + item { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp) + ) { + Text( + "Items (${filteredItems.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + } + + // Item List + if (filteredItems.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + "No items found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + items(filteredItems) { item -> + SimpleItemCard( + item = item, + garages = garages, + onClick = { selectedItemForDetail = item } + ) + } } } } -// ====================================================================== -// MODERN COMPONENTS -// ====================================================================== - @Composable -fun FilterDropdown( +fun FilterDropdownCompact( label: String, icon: ImageVector, expanded: Boolean, @@ -369,18 +442,17 @@ fun FilterDropdown( OutlinedButton( onClick = { onExpandChange(true) }, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(8.dp) ) { - Icon(icon, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) + Icon(icon, null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) Text( label, - modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, - fontSize = 13.sp + fontSize = 12.sp ) - Icon(Icons.Default.ArrowDropDown, null, modifier = Modifier.size(20.dp)) } DropdownMenu( expanded = expanded, @@ -388,7 +460,7 @@ fun FilterDropdown( ) { options.forEach { option -> DropdownMenuItem( - text = { Text(option) }, + text = { Text(option, fontSize = 13.sp) }, onClick = { onSelect(option) } ) } @@ -397,7 +469,7 @@ fun FilterDropdown( } @Composable -fun ModernStatCard( +fun CompactStatCard( title: String, value: String, icon: ImageVector, @@ -405,34 +477,97 @@ fun ModernStatCard( modifier: Modifier = Modifier ) { Card( - modifier = modifier.height(100.dp), - shape = RoundedCornerShape(16.dp), + modifier = modifier.height(90.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)), - elevation = CardDefaults.cardElevation(2.dp) + elevation = CardDefaults.cardElevation(1.dp) ) { Column( modifier = Modifier - .padding(16.dp) + .padding(12.dp) .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.spacedBy(6.dp) ) { Icon( icon, contentDescription = null, tint = color, - modifier = Modifier.size(28.dp) + modifier = Modifier.size(20.dp) ) - Column { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { Text( value, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, - color = color + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Text( title, + style = MaterialTheme.typography.labelSmall, + color = color.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +fun GarageOverviewCard( + garageName: String, + itemCount: Int, + isSelected: Boolean, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .size(width = 140.dp, height = 100.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.tertiaryContainer + ), + elevation = CardDefaults.cardElevation(if (isSelected) 8.dp else 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 = if (isSelected) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onTertiaryContainer + ) + Column { + Text( + text = garageName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onTertiaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "$itemCount items", style = MaterialTheme.typography.bodySmall, - color = color.copy(alpha = 0.8f) + color = if (isSelected) + MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) + else + MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f) ) } } @@ -440,249 +575,334 @@ fun ModernStatCard( } @Composable -fun ExpandableSection( - title: String, - subtitle: String, - icon: ImageVector, - isExpanded: Boolean, - onToggle: () -> Unit, - content: @Composable () -> Unit +fun SimpleItemCard( + item: Item, + garages: List, + onClick: () -> Unit ) { Card( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onToggle), - elevation = CardDefaults.cardElevation(4.dp), - shape = RoundedCornerShape(16.dp) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(1.dp), + shape = RoundedCornerShape(8.dp) ) { - Column(modifier = Modifier.padding(20.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + item.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + getReadableLocation(item, garages), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(16.dp), 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 { + Column(horizontalAlignment = Alignment.End) { + Row { Text( - title, - style = MaterialTheme.typography.titleLarge, + "Qty: ", + style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold ) Text( - subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + "${item.quantity}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Normal + ) + } + val avgPrice = ((item.minPrice ?: 0.0) + (item.maxPrice ?: 0.0)) / 2 + if (avgPrice > 0) { + Text( + "$${String.format("%.0f", avgPrice)}", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF047857), + fontWeight = FontWeight.Bold ) } } - 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() - } + Box( + modifier = Modifier + .size(10.dp) + .background( + when (item.condition.lowercase()) { + "new" -> Color(0xFF047857) + "like new" -> Color(0xFF1E40AF) + "good" -> Color(0xFFB45309) + "fair" -> Color(0xFFC2410C) + "poor" -> Color(0xFF9F1239) + else -> Color(0xFF4B5563) + }, + CircleShape + ) + ) } } } } - +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun AdvancedAnalyticsView( - groupedItems: Map>, - label: String +fun ItemDetailPDFScreen( + item: Item, + garage: Garage?, + onBackClick: () -> Unit ) { - 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) + androidx.compose.material3.Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { + Text( + text = item.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - startAngle += sweepAngle + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } } - - drawCircle( - color = Color.White, - radius = radius * 0.5f, - center = center + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Item Name Header + item { + Text( + text = item.name, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold ) } - 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 + // Model Number (if exists) + item.modelNumber?.let { model -> + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Model Number", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + Text( + model, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } } } } - } - Divider() + // Basic Information Section + item { + DetailSection(title = "📋 Basic Information") { + DetailRow(label = "Quantity", value = item.quantity.toString()) + DetailRow(label = "Condition", value = item.condition) + DetailRow(label = "Functionality", value = item.functionality) + DetailRow(label = "Size Category", value = item.sizeCategory) + } + } - // 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 - ) + // Location Information + item { + DetailSection(title = "📍 Location") { + garage?.let { g -> + DetailRow(label = "Garage", value = g.name) + + val cabinet = g.cabinets.find { it.id == item.cabinetId } + cabinet?.let { c -> + DetailRow(label = "Cabinet", value = c.name) + + val shelf = c.shelves.find { it.id == item.shelfId } + shelf?.let { s -> + DetailRow(label = "Shelf", value = s.name) + + val box = s.boxes.find { it.id == item.boxId } + box?.let { b -> + DetailRow(label = "Box", value = b.name) + } + } + } + } ?: run { + DetailRow(label = "Location", value = "Unknown") + } + + Spacer(Modifier.height(8.dp)) + Text( + "Full Path: ${getReadableLocation(item, listOfNotNull(garage))}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } } - 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) + // Physical Specifications + if (item.dimensions != null || item.weight != null) { + item { + DetailSection(title = "📏 Physical Specifications") { + item.dimensions?.let { + DetailRow(label = "Dimensions", value = it) + } + item.weight?.let { + DetailRow(label = "Weight", value = "$it lbs") + } + } + } + } + + // Pricing Information + val avgPrice = ((item.minPrice ?: 0.0) + (item.maxPrice ?: 0.0)) / 2 + if (avgPrice > 0) { + item { + DetailSection(title = "💰 Pricing") { + item.minPrice?.let { + DetailRow( + label = "Minimum Price", + value = "$${String.format("%.2f", it)}" ) - Spacer(Modifier.width(6.dp)) - Text( - data.name, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis + } + item.maxPrice?.let { + DetailRow( + label = "Maximum Price", + value = "$${String.format("%.2f", it)}" ) } - Text( - "${data.itemCount}", - textAlign = TextAlign.Center, - modifier = Modifier.weight(0.2f), - fontSize = 13.sp + DetailRow( + label = "Estimated Value (Avg)", + value = "$${String.format("%.2f", avgPrice)}", + highlighted = true ) - Text( - "${data.totalQuantity}", - textAlign = TextAlign.Center, - modifier = Modifier.weight(0.2f), - fontSize = 13.sp + DetailRow( + label = "Total Value (× ${item.quantity})", + value = "$${String.format("%.2f", avgPrice * item.quantity)}", + highlighted = true ) + } + } + } + + // Description + item.description?.let { desc -> + if (desc.isNotBlank()) { + item { + DetailSection(title = "📝 Description") { + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + lineHeight = 24.sp + ) + } + } + } + } + + // Web Link + item.webLink?.let { link -> + if (link.isNotBlank()) { + item { + DetailSection(title = "🔗 Web Link") { + Text( + text = link, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + } + } + } + + // Images Section + if (item.images.isNotEmpty()) { + item { + DetailSection(title = "📷 Images") { Text( - "$${String.format("%.0f", data.totalValue)}", - textAlign = TextAlign.End, - modifier = Modifier.weight(0.3f), - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + "${item.images.size} image(s) attached", + style = MaterialTheme.typography.bodyMedium ) } + } + } - 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]) + // Summary Statistics Card + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + "📊 Quick Stats", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer ) + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatBadge( + label = "Quantity", + value = item.quantity.toString(), + icon = Icons.Default.Numbers + ) + if (avgPrice > 0) { + StatBadge( + label = "Value", + value = "$${String.format("%.0f", avgPrice * item.quantity)}", + icon = Icons.Default.AttachMoney + ) + } + item.weight?.let { w -> + StatBadge( + label = "Weight", + value = "${String.format("%.1f", w * item.quantity)} lbs", + icon = Icons.Default.Scale + ) + } + } } } } @@ -691,85 +911,85 @@ fun AdvancedAnalyticsView( } @Composable -fun ModernItemCard( - item: Item, - garages: List +fun DetailSection( + title: String, + content: @Composable () -> Unit ) { Card( modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(2.dp), - shape = RoundedCornerShape(12.dp) + elevation = CardDefaults.cardElevation(2.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) - } + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(12.dp)) + content() } } } @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) +fun DetailRow( + label: String, + value: String, + highlighted: Boolean = false +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(0.5f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Normal, + color = if (highlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(0.5f), + textAlign = TextAlign.End + ) } +} - Box( - modifier = Modifier - .size(12.dp) - .background(color, CircleShape) - ) +@Composable +fun StatBadge( + label: String, + value: String, + icon: ImageVector +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + } } fun getReadableLocation(item: Item, garages: List): String { @@ -785,11 +1005,4 @@ fun getReadableLocation(item: Item, garages: List): String { } 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 +} \ 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 index cae82d9..a96e0b7 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/SearchScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/SearchScreen.kt @@ -10,6 +10,7 @@ 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 @@ -24,9 +25,16 @@ 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.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +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 @@ -37,13 +45,18 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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 @@ -51,18 +64,29 @@ 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 +import kotlinx.coroutines.launch + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( items: List, garages: List, - onItemClick: ((Item) -> Unit)? = null + onItemClick: ((Item) -> Unit)? = null, + onEditItem: ((Item) -> Unit)? = null, + onDeleteItem: ((Item) -> Unit)? = null, + onDuplicateItem: ((Item) -> Unit)? = null, + onShareItem: ((Item) -> Unit)? = null ) { var searchQuery by remember { mutableStateOf("") } var selectedCategory by remember { mutableStateOf("All Categories") } var selectedLocation by remember { mutableStateOf("All Locations") } + var selectedCondition by remember { mutableStateOf("All Conditions") } var isFiltersExpanded by remember { mutableStateOf(false) } + var selectedItem by remember { mutableStateOf(null) } + var showItemActionsSheet by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + // Create dropdown options val categories = remember(items) { listOf("All Categories") + items.map { it.sizeCategory }.distinct().sorted() @@ -72,21 +96,28 @@ fun SearchScreen( listOf("All Locations") + garages.map { it.name }.sorted() } + val conditions = listOf("All Conditions", "Excellent", "Good", "Fair", "Poor") + // 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 filteredItems by remember(searchQuery, selectedCategory, selectedLocation, selectedCondition, items) { + derivedStateOf { + 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 matchesCategory = selectedCategory == "All Categories" || + item.sizeCategory == selectedCategory - val matchesLocation = selectedLocation == "All Locations" || - garages.find { it.id == item.garageId }?.name == selectedLocation + val matchesLocation = selectedLocation == "All Locations" || + garages.find { it.id == item.garageId }?.name == selectedLocation - matchesSearch && matchesCategory && matchesLocation + val matchesCondition = selectedCondition == "All Conditions" || + item.condition == selectedCondition + + matchesSearch && matchesCategory && matchesLocation && matchesCondition + }.sortedBy { it.name } } } @@ -187,6 +218,17 @@ fun SearchScreen( modifier = Modifier.fillMaxWidth() ) + Spacer(modifier = Modifier.height(12.dp)) + + // Condition Dropdown + DropdownMenuField( + label = "Condition", + selectedValue = selectedCondition, + options = conditions, + onValueChange = { selectedCondition = it }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) // Clear Filters Button @@ -194,6 +236,7 @@ fun SearchScreen( onClick = { selectedCategory = "All Categories" selectedLocation = "All Locations" + selectedCondition = "All Conditions" }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp) @@ -227,7 +270,8 @@ fun SearchScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) - if (searchQuery.isNotEmpty() || selectedCategory != "All Categories" || selectedLocation != "All Locations") { + if (searchQuery.isNotEmpty() || selectedCategory != "All Categories" || + selectedLocation != "All Locations" || selectedCondition != "All Conditions") { Text( text = "Clear All", style = MaterialTheme.typography.labelMedium, @@ -237,6 +281,7 @@ fun SearchScreen( searchQuery = "" selectedCategory = "All Categories" selectedLocation = "All Locations" + selectedCondition = "All Conditions" } ) } @@ -260,7 +305,8 @@ fun SearchScreen( ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = if (searchQuery.isEmpty() && selectedCategory == "All Categories" && selectedLocation == "All Locations") { + text = if (searchQuery.isEmpty() && selectedCategory == "All Categories" && + selectedLocation == "All Locations" && selectedCondition == "All Conditions") { "Enter search terms or use filters" } else { "No items found" @@ -273,21 +319,234 @@ fun SearchScreen( } else { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(filteredItems) { item -> - CleanItemCard( + items(filteredItems, key = { it.id }) { item -> + ImprovedItemCard( item = item, garages = garages, - onClick = { onItemClick?.invoke(item) } + onClick = { + selectedItem = item + showItemActionsSheet = true + } + ) + } + } + } + } + + // Item Actions Bottom Sheet + if (showItemActionsSheet && selectedItem != null) { + ItemActionsBottomSheet( + item = selectedItem!!, + garages = garages, + onDismiss = { showItemActionsSheet = false }, + onEdit = { + showItemActionsSheet = false + onEditItem?.invoke(selectedItem!!) + }, + onView = { + showItemActionsSheet = false + onItemClick?.invoke(selectedItem!!) + }, + onDuplicate = { + showItemActionsSheet = false + onDuplicateItem?.invoke(selectedItem!!) + }, + onShare = { + showItemActionsSheet = false + onShareItem?.invoke(selectedItem!!) + }, + onDelete = { + showItemActionsSheet = false + showDeleteDialog = true + } + ) + } + + // Delete Confirmation Dialog + if (showDeleteDialog && selectedItem != null) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Item?") }, + text = { Text("Are you sure you want to delete '${selectedItem!!.name}'? This action cannot be undone.") }, + confirmButton = { + Button( + onClick = { + onDeleteItem?.invoke(selectedItem!!) + showDeleteDialog = false + selectedItem = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel") + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ItemActionsBottomSheet( + item: Item, + garages: List, + onDismiss: () -> Unit, + onEdit: () -> Unit, + onView: () -> Unit, + onDuplicate: () -> Unit, + onShare: () -> Unit, + onDelete: () -> Unit +) { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp) + ) { + // Header with item info + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Text( + text = item.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + if (item.modelNumber != null) { + Text( + text = "Model: ${item.modelNumber}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) ) } + + val locationPath = buildLocationPath(item, garages) + Text( + text = locationPath, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) } + + // Action Items + ActionSheetItem( + icon = Icons.Default.Edit, + title = "Edit Item", + description = "Modify item details and information", + onClick = { + scope.launch { + sheetState.hide() + onEdit() + } + } + ) + + ActionSheetItem( + icon = Icons.Default.ContentCopy, + title = "Duplicate Item", + description = "Create a copy of this item", + onClick = { + scope.launch { + sheetState.hide() + onDuplicate() + } + } + ) + + ActionSheetItem( + icon = Icons.Default.Share, + title = "Share Item", + description = "Share item details with others", + onClick = { + scope.launch { + sheetState.hide() + onShare() + } + } + ) + + ActionSheetItem( + icon = Icons.Default.Delete, + title = "Delete Item", + description = "Permanently remove this item", + isDestructive = true, + onClick = { + scope.launch { + sheetState.hide() + onDelete() + } + } + ) } } } +@Composable +private fun ActionSheetItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + description: String, + isDestructive: Boolean = false, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DropdownMenuField( @@ -301,7 +560,7 @@ private fun DropdownMenuField( ExposedDropdownMenuBox( expanded = expanded, - onExpandedChange = { expanded = !expanded }, + onExpandedChange = { expanded = it }, modifier = modifier ) { OutlinedTextField( @@ -334,7 +593,7 @@ private fun DropdownMenuField( } @Composable -private fun CleanItemCard( +private fun ImprovedItemCard( item: Item, garages: List, onClick: () -> Unit @@ -432,8 +691,8 @@ private fun CleanItemCard( // Right Arrow Icon( - imageVector = Icons.Default.KeyboardArrowRight, - contentDescription = "View Details", + imageVector = Icons.Default.MoreVert, + contentDescription = "More Options", tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/SettingScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/SettingScreen.kt index cc87f1f..a291938 100644 --- a/app/src/main/java/com/samuel/inventorymanager/screens/SettingScreen.kt +++ b/app/src/main/java/com/samuel/inventorymanager/screens/SettingScreen.kt @@ -1,9 +1,10 @@ +// *** REPLACE THE ENTIRE CONTENTS of SettingsScreen.kt with this final code *** + @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 @@ -33,30 +34,18 @@ 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.DeleteForever 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 @@ -67,15 +56,13 @@ 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -91,46 +78,73 @@ 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.platform.LocalLifecycleOwner 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 androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.google.gson.Gson 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.io.OutputStreamWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +// Helper function to check permission state +private fun checkStoragePermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } +} + + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( currentSettings: AppSettings, - onSettingsChange: (AppSettings) -> Unit + currentData: AppData, + onSettingsChange: (AppSettings) -> Unit, + onDataChange: (AppData) -> Unit, + onClearAllData: () -> Unit ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val authManager = remember { GoogleAuthManager(context) } + val lifecycleOwner = LocalLifecycleOwner.current var settings by remember { mutableStateOf(currentSettings) } var showColorPicker by remember { mutableStateOf(false) } - var showResetDialog by remember { mutableStateOf(false) } + var showClearDataDialog by remember { mutableStateOf(false) } var isProcessing by remember { mutableStateOf(false) } var feedbackMessage by remember { mutableStateOf(null) } + var hasStoragePermission by remember { mutableStateOf(checkStoragePermission(context)) } + + // This observer re-checks the permission every time the user returns to this screen. THIS IS THE FIX. + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasStoragePermission = checkStoragePermission(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + LaunchedEffect(currentSettings) { settings = currentSettings @@ -139,154 +153,105 @@ fun SettingsScreen( LaunchedEffect(feedbackMessage) { feedbackMessage?.let { message -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - kotlinx.coroutines.delay(2000) + kotlinx.coroutines.delay(2500) feedbackMessage = null } } val storagePermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - val granted = permissions.values.all { it } - feedbackMessage = if (granted) "✅ Storage permission granted" else "❌ Storage permission denied" + ) { + hasStoragePermission = checkStoragePermission(context) + feedbackMessage = if (hasStoragePermission) "✅ Storage granted" else "❌ Storage 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" + // This runs after the user returns from the system settings screen + hasStoragePermission = checkStoragePermission(context) + feedbackMessage = if (hasStoragePermission) "✅ Storage permission granted!" else "❌ Storage permission was not granted." } 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}") + data = "package:${context.packageName}".toUri() } manageStorageLauncher.launch(intent) + } else { + feedbackMessage = "✅ Storage permission is already granted." } } else { storagePermissionLauncher.launch( - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) + 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}" + val jsonImportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + uri?.let { + scope.launch { + isProcessing = true + val result = importAppDataFromJson(context, it) + isProcessing = false + result?.let { importedData -> + onDataChange(importedData) + feedbackMessage = "✅ Data imported successfully" + } ?: run { feedbackMessage = "❌ Import failed. Invalid file." } } - } else { - feedbackMessage = "❌ Sign in cancelled" } } - val onSignInClick: () -> Unit = { - val signInIntent = authManager.getSignInIntent() - googleSignInLauncher.launch(signInIntent) - } - - val importLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.GetContent() + val jsonExportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") ) { uri: Uri? -> uri?.let { scope.launch { isProcessing = true - val result = importSettings(context, it) + val success = exportAppDataToJson(context, currentData, it) isProcessing = false - result?.let { importedSettings -> - settings = importedSettings - onSettingsChange(importedSettings) - feedbackMessage = "✅ Settings imported successfully" - } ?: run { - feedbackMessage = "❌ Failed to import settings" - } + feedbackMessage = if (success) "✅ Backup exported successfully" else "❌ Export failed" } } } - val exportLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/json") + val csvExportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("text/csv") ) { uri: Uri? -> uri?.let { scope.launch { isProcessing = true - val success = exportSettings(context, settings, it) + val success = exportAppDataToCsv(context, currentData, it) isProcessing = false - feedbackMessage = if (success) "✅ Settings exported successfully" - else "❌ Failed to export settings" + feedbackMessage = if (success) "✅ CSV exported successfully" else "❌ Export failed" } } } - 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) } + var themeExpanded by remember { mutableStateOf(false) } + var dataExpanded by remember { mutableStateOf(true) } Box(modifier = Modifier.fillMaxSize()) { LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + 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 - ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text("⚙️ Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer) + Text("Manage themes, data, and backups.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer) } } } - item { ExpandableCard( - title = "Theme Settings", + title = "🎨 Theme & Appearance", icon = Icons.Default.Palette, expanded = themeExpanded, onToggle = { themeExpanded = !themeExpanded } @@ -300,149 +265,49 @@ fun SettingsScreen( 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", + title = "💾 Data Management", icon = Icons.Default.Storage, expanded = dataExpanded, onToggle = { dataExpanded = !dataExpanded } ) { DataManagementContent( - autoFeatures = settings.autoFeatures, + hasPermission = hasStoragePermission, // Pass the reactive state here onRequestPermissions = requestStoragePermission, - onUpdate = { newAutoFeatures -> - settings = settings.copy(autoFeatures = newAutoFeatures) - onSettingsChange(settings) + onExportJson = { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + jsonExportLauncher.launch("inventory_backup_$timestamp.json") }, - 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" - } - } + onImportJson = { jsonImportLauncher.launch("application/json") }, + onExportCsv = { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + csvExportLauncher.launch("inventory_export_$timestamp.csv") } ) } } - + item { Spacer(Modifier.height(16.dp)) } item { - Button( - onClick = { showResetDialog = true }, + OutlinedButton( + onClick = { showClearDataDialog = true }, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error) ) { - Icon(Icons.Default.RestartAlt, null) + Icon(Icons.Default.DeleteForever, null) Spacer(Modifier.width(8.dp)) - Text("Reset All Settings") + Text("Clear All Data") } } } - if (isProcessing) { Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)), + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)), contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) @@ -462,41 +327,33 @@ fun SettingsScreen( ) } - if (showResetDialog) { + if (showClearDataDialog) { AlertDialog( - onDismissRequest = { showResetDialog = false }, - title = { Text("Reset Settings") }, - text = { Text("Are you sure you want to reset all settings to default?") }, + onDismissRequest = { showClearDataDialog = false }, + title = { Text("Clear All Data?") }, + text = { Text("This will permanently delete all your inventory data and settings. This action cannot be undone.") }, confirmButton = { Button( onClick = { - settings = AppSettings() - onSettingsChange(settings) - showResetDialog = false - feedbackMessage = "✅ Settings reset to default" + onClearAllData() + showClearDataDialog = false + feedbackMessage = "✅ All application data has been cleared." }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - ) { Text("Reset") } + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { Text("Clear Everything") } }, - dismissButton = { - TextButton({ showResetDialog = false }) { Text("Cancel") } - } + dismissButton = { TextButton({ showClearDataDialog = false }) { Text("Cancel") } } ) } } -// ======================================================================================== -// HELPER FUNCTIONS -// ======================================================================================== +// DATA I/O FUNCTIONS and UI HELPERS remain the same, just moved outside the main composable -suspend fun exportSettings(context: Context, settings: AppSettings, uri: Uri): Boolean { +private suspend fun exportAppDataToJson(context: Context, appData: AppData, uri: Uri): Boolean { return withContext(Dispatchers.IO) { try { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(settings.toJson().toByteArray()) - } + val jsonString = Gson().toJson(appData) + context.contentResolver.openOutputStream(uri)?.use { it.write(jsonString.toByteArray()) } true } catch (e: Exception) { e.printStackTrace() @@ -505,12 +362,12 @@ suspend fun exportSettings(context: Context, settings: AppSettings, uri: Uri): B } } -suspend fun importSettings(context: Context, uri: Uri): AppSettings? { +private suspend fun importAppDataFromJson(context: Context, uri: Uri): AppData? { return withContext(Dispatchers.IO) { try { - context.contentResolver.openInputStream(uri)?.use { inputStream -> - val json = inputStream.bufferedReader().readText() - AppSettings.fromJson(json) + context.contentResolver.openInputStream(uri)?.use { + val json = it.bufferedReader().readText() + Gson().fromJson(json, AppData::class.java) } } catch (e: Exception) { e.printStackTrace() @@ -519,11 +376,31 @@ suspend fun importSettings(context: Context, uri: Uri): AppSettings? { } } -suspend fun performLocalSave(context: Context, settings: AppSettings): Boolean { +private suspend fun exportAppDataToCsv(context: Context, appData: AppData, uri: Uri): Boolean { return withContext(Dispatchers.IO) { try { - val file = java.io.File(context.filesDir, "app_settings.json") - file.writeText(settings.toJson()) + val garageMap = appData.garages.associateBy { it.id } + val cabinetMap = appData.garages.flatMap { it.cabinets }.associateBy { it.id } + val shelfMap = appData.garages.flatMap { it.cabinets }.flatMap { it.shelves }.associateBy { it.id } + val boxMap = appData.garages.flatMap { it.cabinets }.flatMap { it.shelves }.flatMap { it.boxes }.associateBy { it.id } + + context.contentResolver.openOutputStream(uri)?.use { stream -> + OutputStreamWriter(stream).use { writer -> + writer.appendLine("\"itemID\",\"itemName\",\"quantity\",\"condition\",\"functionality\",\"garageName\",\"cabinetName\",\"shelfName\",\"boxName\",\"modelNumber\",\"description\",\"webLink\",\"minPrice\",\"maxPrice\",\"weight\",\"sizeCategory\",\"dimensions\"") + appData.items.forEach { item -> + val row = listOf( + item.id, item.name, item.quantity.toString(), item.condition, + item.functionality, garageMap[item.garageId]?.name ?: "N/A", + cabinetMap[item.cabinetId]?.name ?: "N/A", shelfMap[item.shelfId]?.name ?: "N/A", + item.boxId?.let { boxMap[it]?.name } ?: "", item.modelNumber ?: "", + item.description ?: "", item.webLink ?: "", + item.minPrice?.toString() ?: "", item.maxPrice?.toString() ?: "", + item.weight?.toString() ?: "", item.sizeCategory, item.dimensions ?: "" + ).joinToString(",") { "\"${it.replace("\"", "\"\"")}\"" } + writer.appendLine(row) + } + } + } true } catch (e: Exception) { e.printStackTrace() @@ -532,48 +409,25 @@ suspend fun performLocalSave(context: Context, settings: AppSettings): Boolean { } } -// ======================================================================================== -// COMPOSABLE COMPONENTS -// ======================================================================================== - @Composable -fun ExpandableCard( - title: String, - icon: ImageVector, - expanded: Boolean, - onToggle: () -> Unit, +private fun ExpandableCard( + title: String, icon: ImageVector, expanded: Boolean, onToggle: () -> Unit, content: @Composable ColumnScope.() -> Unit ) { - Card( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(2.dp) - ) { + Card(modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(2.dp)) { Column { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(16.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon(icon, null, tint = MaterialTheme.colorScheme.primary) + Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp)) 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 - ) + 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(), + Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp), content = content ) @@ -583,449 +437,26 @@ fun ExpandableCard( } @Composable -fun ThemeSettingsContent( - settings: AppSettings, - onThemeChange: (AppTheme) -> Unit, - onFontSizeChange: (FontSize) -> Unit, - onCustomThemeClick: () -> Unit +private fun DataManagementContent( + hasPermission: Boolean, // Takes the state as a parameter now + onRequestPermissions: () -> Unit, onExportJson: () -> Unit, onImportJson: () -> Unit, onExportCsv: () -> 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()) { + if (!hasPermission) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) ) { - 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) - ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Warning, - "Warning", - tint = MaterialTheme.colorScheme.error - ) + 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("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 - ) + Text("To import or export data files, the app needs storage access.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer) Button( - onClick = onRequestPermissions, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) + onClick = onRequestPermissions, modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) ) { Icon(Icons.Default.Folder, null) Spacer(Modifier.width(8.dp)) @@ -1036,176 +467,77 @@ fun DataManagementContent( } else { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) + 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 - ) + 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 - ) + 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() + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Backup & Restore (JSON)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Use a JSON file to create a complete backup of your app data or restore from a previous backup.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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)) + OutlinedButton(onClick = onImportJson, modifier = Modifier.weight(1f), enabled = hasPermission) { + Icon(Icons.Default.Download, null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.width(8.dp)) Text("Import") } + OutlinedButton(onClick = onExportJson, modifier = Modifier.weight(1f), enabled = hasPermission) { + Icon(Icons.Default.Upload, null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.width(8.dp)) + Text("Export") + } } - } -} -@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 - ) -} + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) -@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) + Text("Export for Spreadsheet (CSV)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Export your item list as a CSV file to open in Excel or Google Sheets.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + + Button(onClick = onExportCsv, modifier = Modifier.fillMaxWidth(), enabled = hasPermission) { + Icon(Icons.Default.Upload, null) + Spacer(Modifier.width(8.dp)) + Text("Export Items to CSV") + } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun CustomThemeDialog( - currentTheme: CustomTheme, - onDismiss: () -> Unit, - onConfirm: (CustomTheme) -> Unit +private 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) + "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) - } + 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) -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rowColors.forEach { (_, color) -> Box( - Modifier - .weight(1f) - .aspectRatio(1f) - .clip(RoundedCornerShape(12.dp)) - .background(color) - .clickable { - theme = theme.copy( - primaryColor = color.toArgb().toLong() - ) - }, + 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) - ) + Icon(Icons.Default.Check, null, tint = Color.White, modifier = Modifier.size(24.dp)) } } } @@ -1213,144 +545,128 @@ fun CustomThemeDialog( Spacer(Modifier.height(8.dp)) } } - item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } - + item { Text("Select Background", fontWeight = FontWeight.Bold) } 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) -> + 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() - ) - }, + 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 - ) + 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 { 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 + value = fontSizeScale, onValueChange = { fontSizeScale = it }, valueRange = 0.8f..1.5f, + steps = 13, modifier = Modifier.fillMaxWidth() ) + Text(String.format(Locale.US, "Scale: %.2f (0.8x to 1.5x)", fontSizeScale), style = MaterialTheme.typography.bodySmall) } } } }, - confirmButton = { - Button({ - onConfirm(theme.copy(fontSizeScale = fontSizeScale)) - }) { Text("Apply Theme") } - }, - dismissButton = { - TextButton(onDismiss) { Text("Cancel") } - } + 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)? +private fun ThemeSettingsContent( + settings: AppSettings, onThemeChange: (AppTheme) -> Unit, onFontSizeChange: (FontSize) -> Unit ) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Select Theme", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + val themeItems = listOf( + "☀️ Light" to AppTheme.LIGHT, "🌙 Dark" to AppTheme.DARK, "🧛 Dracula" to AppTheme.DRACULA, + "🌊 Ocean" to AppTheme.OCEAN, "🌲 Forest" to AppTheme.FOREST, "🌅 Sunset" to AppTheme.SUNSET ) - ) { - 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 + themeItems.chunked(2).forEach { rowItems -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rowItems.forEach { (name, theme) -> + ThemePresetCard( + name = name, color = getThemeColor(theme), isSelected = settings.theme == theme, + onClick = { onThemeChange(theme) }, modifier = Modifier.weight(1f) ) } - Spacer(Modifier.width(12.dp)) - Text(name, fontWeight = FontWeight.Medium) + if (rowItems.size == 1) Spacer(modifier = Modifier.weight(1f)) } - 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 - ) + } + HorizontalDivider(Modifier.padding(vertical = 12.dp)) + Text("Font Size", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FontSize.entries.forEach { size -> + FontSizeButton(size.name.lowercase().replaceFirstChar { it.uppercase() }, settings.fontSize == size) { onFontSizeChange(size) } + } + } + Text("Current size: ${settings.fontSize.name} (${String.format(Locale.US, "%.2f", settings.fontSize.scale)}x)", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp) + ) + } +} + +private fun getThemeColor(theme: AppTheme): Color { + return when (theme) { + AppTheme.LIGHT -> Color(0xFFE3F2FD) + AppTheme.DARK -> Color(0xFF212121) + AppTheme.DRACULA -> Color(0xFFBD93F9) + AppTheme.OCEAN -> Color(0xFF00B4D8) + AppTheme.FOREST -> Color(0xFF2D6A4F) + AppTheme.SUNSET -> Color(0xFFFF6B35) + AppTheme.VAMPIRE -> Color(0xFFFF1493) + AppTheme.CYBERPUNK -> Color(0xFFFF006E) + AppTheme.NEON -> Color(0xFF39FF14) + AppTheme.SYSTEM -> Color(0xFF808080) + AppTheme.CUSTOM -> Color(0xFF6200EE) + } +} + +@Composable +private fun ThemePresetCard( + name: String, color: Color, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier +) { + Card( + modifier = modifier.height(60.dp).clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.2f)), + border = if (isSelected) BorderStroke(3.dp, color) else null + ) { + Box( + modifier = Modifier.fillMaxSize().background(Brush.linearGradient(listOf(color.copy(0.4f), color.copy(0.1f)))), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(name, color = if (themeIsDark(color)) Color.White else Color.Black, fontWeight = FontWeight.Bold, fontSize = 13.sp) + if (isSelected) { + Spacer(Modifier.width(4.dp)) + Icon(Icons.Default.Check, null, tint = color, modifier = Modifier.size(14.dp)) } } } } +} + +private fun themeIsDark(color: Color): Boolean { + val darkness = 1 - (0.299 * color.red + 0.587 * color.green + 0.114 * color.blue) + return darkness >= 0.5 +} + +@Composable +private 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, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text(text, fontSize = 11.sp, maxLines = 1) + } } \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/screens/ShareScreen.kt b/app/src/main/java/com/samuel/inventorymanager/screens/ShareScreen.kt new file mode 100644 index 0000000..b6c11ba --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/screens/ShareScreen.kt @@ -0,0 +1,1047 @@ +@file:SuppressLint("UnusedBoxWithConstraintsScope") + +package com.samuel.inventorymanager.screens + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.widget.Toast +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.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +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.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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.filled.AccountCircle +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.Visibility +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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +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.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.Brush +import androidx.compose.ui.graphics.asImageBitmap +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.DatabaseReference +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener +import com.google.firebase.database.ktx.database +import com.google.firebase.ktx.Firebase +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.random.Random + +// --- DATA CLASSES FOR SHARING --- + +data class ShareData( + val code: String = "", + val data: String = "", + val createdAt: Long = 0, + val expiresAt: Long = 0, + val creatorName: String = "Anonymous" +) + +enum class ShareSection { + RECEIVED, + SHARED, +} + +data class SharedGarageData( + val code: String, + val name: String, + val owner: String, + val sharedAt: Long, + val itemCount: Int, + val data: AppData // The full inventory data +) + +data class ActiveShare( + val code: String, + val name: String, + val createdAt: Long, + val expiresAt: Long, + val itemCount: Int +) + +data class UserProfile( + var username: String = "User", + var email: String = "", + var sharesCount: Int = 0, + var receivedCount: Int = 0 +) + +data class ShareHistoryItem( + val code: String, + val timestamp: Long, + val type: String, // "GENERATED" or "IMPORTED" + val itemCount: Int +) + +enum class ShareExpiry(val label: String, val milliseconds: Long) { + MINUTES_30("30 Minutes", 30 * 60 * 1000L), + HOURS_24("24 Hours", 24 * 60 * 60 * 1000L), + DAYS_7("7 Days", 7 * 24 * 60 * 60 * 1000L), + DAYS_30("30 Days", 30 * 24 * 60 * 60 * 1000L) +} + + +// --- MAIN COMPOSABLE --- + +@Composable +fun ShareScreen( + garages: List, + items: List, + history: List, + onDataImported: (AppData) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val database: DatabaseReference = remember { Firebase.database.reference } + + var selectedSection by remember { mutableStateOf(ShareSection.RECEIVED) } + var isLoading by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf("") } + var generatedCode by remember { mutableStateOf(null) } + var qrBitmap by remember { mutableStateOf(null) } + var importCode by remember { mutableStateOf("") } + var shareExpiry by remember { mutableStateOf(ShareExpiry.DAYS_7) } + var shareHistory by remember { mutableStateOf>(emptyList()) } + var showExpiryDialog by remember { mutableStateOf(false) } + var sharedGarages by remember { mutableStateOf>(emptyList()) } + var myActiveShares by remember { mutableStateOf>(emptyList()) } + var userProfile by remember { mutableStateOf(UserProfile()) } + var showProfileDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + // Load local data on start + shareHistory = loadShareHistory(context) + myActiveShares = loadMyActiveShares(context) + sharedGarages = loadSharedGarages(context) + userProfile = loadUserProfile(context) + } + + LaunchedEffect(statusMessage) { + if (statusMessage.isNotEmpty()) { + delay(3000) + statusMessage = "" + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(androidx.compose.ui.graphics.Color(0xFF0F172A)) + ) { + Column(modifier = Modifier.fillMaxSize()) { + ShareHeader( + onProfileClick = { showProfileDialog = true } + ) + + // Section Tabs + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SectionTab( + icon = Icons.Default.Download, + title = "Received", + count = sharedGarages.size, + color = androidx.compose.ui.graphics.Color(0xFF10B981), + isSelected = selectedSection == ShareSection.RECEIVED, + onClick = { selectedSection = ShareSection.RECEIVED }, + modifier = Modifier.weight(1f) + ) + SectionTab( + icon = Icons.Default.Upload, + title = "My Shares", + count = myActiveShares.size, + color = androidx.compose.ui.graphics.Color(0xFF8B5CF6), + isSelected = selectedSection == ShareSection.SHARED, + onClick = { selectedSection = ShareSection.SHARED }, + modifier = Modifier.weight(1f) + ) + } + + // Content Area + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + when (selectedSection) { + ShareSection.RECEIVED -> ReceivedSection( + sharedGarages = sharedGarages, + importCode = importCode, + isLoading = isLoading, + onCodeChange = { importCode = it.uppercase().trim() }, + onImport = { + if (importCode.isBlank()) { + statusMessage = "⚠️ Code cannot be empty" + return@ReceivedSection + } + scope.launch { + isLoading = true + try { + val snapshot: DataSnapshot = database.child("shares").child(importCode).get().await() + val shareData = snapshot.getValue(ShareData::class.java) + if (shareData != null) { + if (System.currentTimeMillis() > shareData.expiresAt) { + statusMessage = "⚠️ Code has expired" + } else { + val appData = Gson().fromJson(shareData.data, AppData::class.java) + + val newSharedGarage = SharedGarageData( + code = importCode, + name = "${shareData.creatorName}'s Garage", + owner = shareData.creatorName, + sharedAt = System.currentTimeMillis(), + itemCount = appData.items.size, + data = appData + ) + // Prevent duplicates + sharedGarages = (listOf(newSharedGarage) + sharedGarages).distinctBy { it.code } + saveSharedGarages(context, sharedGarages) + + val historyItem = ShareHistoryItem(code = importCode, timestamp = System.currentTimeMillis(), type = "IMPORTED", itemCount = appData.items.size) + shareHistory = (listOf(historyItem) + shareHistory).distinctBy { it.code } + saveShareHistory(context, shareHistory) + + userProfile.receivedCount = sharedGarages.size + saveUserProfile(context, userProfile) + + statusMessage = "✅ Added to Received Garages!" + importCode = "" + } + } else { + statusMessage = "❌ Invalid code" + } + } catch (e: Exception) { + statusMessage = "❌ Import failed: ${e.message}" + } finally { + isLoading = false + } + } + }, + onViewGarage = { sharedGarage -> + Toast.makeText(context, "Viewing ${sharedGarage.name}. \n(Functionality to be implemented)", Toast.LENGTH_SHORT).show() + }, + onImportGarage = { sharedGarage -> + onDataImported(sharedGarage.data) + statusMessage = "✅ Imported to your main inventory!" + }, + onDeleteGarage = { sharedGarage -> + sharedGarages = sharedGarages.filter { it.code != sharedGarage.code } + saveSharedGarages(context, sharedGarages) + userProfile.receivedCount = sharedGarages.size + saveUserProfile(context, userProfile) + statusMessage = "🗑️ Removed from received list" + } + ) + + ShareSection.SHARED -> MySharesSection( + garages = garages, + items = items, + history = history, + myActiveShares = myActiveShares, + generatedCode = generatedCode, + qrBitmap = qrBitmap, + shareExpiry = shareExpiry, + isLoading = isLoading, + onExpiryClick = { showExpiryDialog = true }, + onGenerate = { + if (items.isEmpty()){ + statusMessage = "⚠️ Cannot share an empty inventory" + return@MySharesSection + } + scope.launch { + isLoading = true + try { + val code = generateShareCode() + val appData = AppData(garages, items, history) + val shareData = ShareData( + code = code, + data = Gson().toJson(appData), + createdAt = System.currentTimeMillis(), + expiresAt = System.currentTimeMillis() + shareExpiry.milliseconds, + creatorName = userProfile.username + ) + database.child("shares").child(code).setValue(shareData).await() + generatedCode = code + qrBitmap = generateQRCode(code) + + val activeShare = ActiveShare(code = code, name = "My Inventory Share", createdAt = System.currentTimeMillis(), expiresAt = shareData.expiresAt, itemCount = items.size) + myActiveShares = (listOf(activeShare) + myActiveShares).distinctBy { it.code } + saveMyActiveShares(context, myActiveShares) + + val historyItem = ShareHistoryItem(code = code, timestamp = System.currentTimeMillis(), type = "GENERATED", itemCount = items.size) + shareHistory = (listOf(historyItem) + shareHistory).distinctBy { it.code } + saveShareHistory(context, shareHistory) + + userProfile.sharesCount = myActiveShares.size + saveUserProfile(context, userProfile) + + statusMessage = "✅ Share code created successfully!" + } catch (e: Exception) { + statusMessage = "❌ Generation failed: ${e.message}" + } finally { + isLoading = false + } + } + }, + onCopyCode = { code -> + copyToClipboard(context, code) + statusMessage = "📋 Copied code to clipboard!" + }, + onCopyLink = { code -> + val link = "inventorymanager://share/$code" + copyToClipboard(context, link) + statusMessage = "🔗 Copied share link!" + }, + onDeleteShare = { activeShare -> + myActiveShares = myActiveShares.filter { it.code != activeShare.code } + saveMyActiveShares(context, myActiveShares) + userProfile.sharesCount = myActiveShares.size + saveUserProfile(context, userProfile) + + // Also delete from Firebase + database.child("shares").child(activeShare.code).removeValue() + + statusMessage = "🗑️ Share code deleted" + } + ) + } + + // --- Status Message Display --- + this@Column.AnimatedVisibility( + visible = statusMessage.isNotEmpty(), + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomCenter).padding(20.dp) + ) { + Card( + colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color(0xFF1E293B)), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Text( + statusMessage, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + color = androidx.compose.ui.graphics.Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize().background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = androidx.compose.ui.graphics.Color(0xFF8B5CF6), + modifier = Modifier.size(64.dp) + ) + } + } + } + + // --- Dialogs --- + + if (showExpiryDialog) { + AlertDialog( + onDismissRequest = { showExpiryDialog = false }, + icon = { Icon(Icons.Default.Schedule, null) }, + title = { Text("Set Share Expiry") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ShareExpiry.entries.forEach { expiry -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + shareExpiry = expiry + showExpiryDialog = false + } + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(expiry.label) + if (shareExpiry == expiry) { + Icon(Icons.Default.Check, null, tint = androidx.compose.ui.graphics.Color(0xFF10B981)) + } + } + } + } + }, + confirmButton = { TextButton(onClick = { showExpiryDialog = false }) { Text("Close") } } + ) + } + + if (showProfileDialog) { + ProfileDialog( + userProfile = userProfile, + onDismiss = { showProfileDialog = false }, + onSave = { newProfile -> + userProfile = newProfile + saveUserProfile(context, newProfile) + showProfileDialog = false + statusMessage = "✅ Profile updated!" + } + ) + } +} + + +// --- HELPER COMPOSABLES & FUNCTIONS --- + +@Composable +private fun ShareHeader(onProfileClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + .background(Brush.verticalGradient(colors = listOf(androidx.compose.ui.graphics.Color(0xFF6366F1), androidx.compose.ui.graphics.Color(0xFF8B5CF6)))) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Surface(shape = CircleShape, color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.2f), modifier = Modifier.size(60.dp)) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Default.Share, null, modifier = Modifier.size(32.dp), tint = androidx.compose.ui.graphics.Color.White) + } + } + Column { + Text("Share Hub", color = androidx.compose.ui.graphics.Color.White, fontSize = 28.sp, fontWeight = FontWeight.ExtraBold) + Text("Manage Your Network", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f), fontSize = 14.sp) + } + } + IconButton( + onClick = onProfileClick, + modifier = Modifier + .size(48.dp) + .background(androidx.compose.ui.graphics.Color.White.copy(alpha = 0.2f), CircleShape) + ) { + Icon(Icons.Default.AccountCircle, "Profile", tint = androidx.compose.ui.graphics.Color.White, modifier = Modifier.size(28.dp)) + } + } + } +} + + +@Composable +private fun ProfileDialog( + userProfile: UserProfile, + onDismiss: () -> Unit, + onSave: (UserProfile) -> Unit +) { + var username by remember { mutableStateOf(userProfile.username) } + var email by remember { mutableStateOf(userProfile.email) } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.AccountCircle, null, modifier = Modifier.size(48.dp)) }, + title = { Text("Your Profile", fontSize = 24.sp, fontWeight = FontWeight.Bold) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Username") }, + leadingIcon = { Icon(Icons.Default.Person, null) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email (Optional)") }, + leadingIcon = { Icon(Icons.Default.Email, null) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email) + ) + + HorizontalDivider() + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(userProfile.sharesCount.toString(), fontSize = 24.sp, fontWeight = FontWeight.Bold, color = androidx.compose.ui.graphics.Color(0xFF8B5CF6)) + Text("Shares Created", fontSize = 12.sp) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(userProfile.receivedCount.toString(), fontSize = 24.sp, fontWeight = FontWeight.Bold, color = androidx.compose.ui.graphics.Color(0xFF10B981)) + Text("Shares Received", fontSize = 12.sp) + } + } + } + }, + confirmButton = { Button(onClick = { onSave(UserProfile(username, email, userProfile.sharesCount, userProfile.receivedCount)) }) { Text("Save") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} + +@Composable +private fun SectionTab( + icon: ImageVector, + title: String, + count: Int, + color: androidx.compose.ui.graphics.Color, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + modifier = modifier.height(90.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = if (isSelected) color.copy(alpha = 0.2f) else androidx.compose.ui.graphics.Color(0xFF1E293B)), + border = if (isSelected) BorderStroke(2.dp, color) else null, + elevation = CardDefaults.cardElevation(if (isSelected) 8.dp else 2.dp) + ) { + Column( + modifier = Modifier.fillMaxSize().padding(12.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(icon, contentDescription = null, tint = if (isSelected) color else androidx.compose.ui.graphics.Color.White.copy(alpha = 0.6f), modifier = Modifier.size(28.dp)) + Spacer(Modifier.height(6.dp)) + Text(title, color = androidx.compose.ui.graphics.Color.White, fontSize = 14.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) + Text("$count total", color = if (isSelected) color else androidx.compose.ui.graphics.Color.White.copy(alpha = 0.5f), fontSize = 11.sp, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun ReceivedSection( + sharedGarages: List, + importCode: String, + isLoading: Boolean, + onCodeChange: (String) -> Unit, + onImport: () -> Unit, + onViewGarage: (SharedGarageData) -> Unit, + onImportGarage: (SharedGarageData) -> Unit, + onDeleteGarage: (SharedGarageData) -> Unit +) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Import Card + Card( + colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color(0xFF1E293B)), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface(shape = CircleShape, color = androidx.compose.ui.graphics.Color(0xFF10B981).copy(alpha = 0.2f), modifier = Modifier.size(48.dp)) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Default.Add, null, tint = androidx.compose.ui.graphics.Color(0xFF10B981), modifier = Modifier.size(24.dp)) + } + } + Column { + Text("Import Share Code", color = androidx.compose.ui.graphics.Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Text("Add someone's garage to your list", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.6f), fontSize = 13.sp) + } + } + + OutlinedTextField( + value = importCode, + onValueChange = onCodeChange, + label = { Text("Enter Code") }, + placeholder = { Text("ABC-123") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = androidx.compose.ui.graphics.Color(0xFF10B981), + unfocusedBorderColor = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.3f), + cursorColor = androidx.compose.ui.graphics.Color(0xFF10B981), + focusedLabelColor = androidx.compose.ui.graphics.Color(0xFF10B981), + unfocusedLabelColor = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.5f), + focusedTextColor = androidx.compose.ui.graphics.Color.White, + unfocusedTextColor = androidx.compose.ui.graphics.Color.White + ) + ) + + Button( + onClick = onImport, + modifier = Modifier.fillMaxWidth().height(52.dp), + colors = ButtonDefaults.buttonColors(containerColor = androidx.compose.ui.graphics.Color(0xFF10B981)), + shape = RoundedCornerShape(12.dp), + enabled = !isLoading && importCode.isNotBlank() + ) { + Icon(Icons.Default.Download, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(10.dp)) + Text("Add to Received", fontSize = 15.sp, fontWeight = FontWeight.Bold) + } + } + } + + // Received Garages List + if (sharedGarages.isNotEmpty()){ + Text( + "Received Garages (${sharedGarages.size})", + color = androidx.compose.ui.graphics.Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp) + ) + } + + if (sharedGarages.isEmpty()) { + Card(colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color(0xFF1E293B)), shape = RoundedCornerShape(20.dp)) { + Column( + modifier = Modifier.fillMaxWidth().padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(Icons.Default.Download, null, modifier = Modifier.size(56.dp), tint = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.2f)) + Text("No Garages Yet", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.5f), fontSize = 16.sp, fontWeight = FontWeight.Medium) + Text("Import a code above to get started", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.3f), fontSize = 13.sp, textAlign = TextAlign.Center) + } + } + } else { + sharedGarages.forEach { sharedGarage -> + ReceivedGarageCard( + sharedGarage = sharedGarage, + onView = { onViewGarage(sharedGarage) }, + onImport = { onImportGarage(sharedGarage) }, + onDelete = { onDeleteGarage(sharedGarage) } + ) + } + } + } +} + +@Composable +private fun ReceivedGarageCard( + sharedGarage: SharedGarageData, + onView: () -> Unit, + onImport: () -> Unit, + onDelete: () -> Unit +) { + Card( + colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color(0xFF1E293B)), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + Text(sharedGarage.name, color = androidx.compose.ui.graphics.Color.White, fontSize = 19.sp, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Person, null, tint = androidx.compose.ui.graphics.Color(0xFF10B981), modifier = Modifier.size(16.dp)) + Text("By ${sharedGarage.owner}", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.7f), fontSize = 13.sp) + } + } + Column(horizontalAlignment = Alignment.End) { + Text(formatTimestamp(sharedGarage.sharedAt), color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.4f), fontSize = 11.sp) + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Delete, null, tint = androidx.compose.ui.graphics.Color(0xFFEF4444), modifier = Modifier.size(20.dp)) + } + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + StatPill("Items", sharedGarage.itemCount.toString(), androidx.compose.ui.graphics.Color(0xFF8B5CF6)) + StatPill("Garages", sharedGarage.data.garages.size.toString(), androidx.compose.ui.graphics.Color(0xFF3B82F6)) + StatPill("Logs", sharedGarage.data.history.size.toString(), androidx.compose.ui.graphics.Color(0xFFF59E0B)) + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton( + onClick = onView, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = androidx.compose.ui.graphics.Color(0xFF3B82F6)), + border = BorderStroke(2.dp, androidx.compose.ui.graphics.Color(0xFF3B82F6)) + ) { + Icon(Icons.Default.Visibility, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("View", fontWeight = FontWeight.Bold) + } + Button( + onClick = onImport, + modifier = Modifier.weight(1f).height(48.dp), + colors = ButtonDefaults.buttonColors(containerColor = androidx.compose.ui.graphics.Color(0xFF10B981)), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Download, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Import All", fontWeight = FontWeight.Bold) + } + } + } + } +} + +@Composable +private fun StatPill(label: String, value: String, color: androidx.compose.ui.graphics.Color) { + Surface(shape = RoundedCornerShape(10.dp), color = color.copy(alpha = 0.15f)) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(value, color = color, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text(label, color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.6f), fontSize = 12.sp) + } + } +} + +@Composable +private fun MySharesSection( + garages: List, + items: List, + history: List, + myActiveShares: List, + generatedCode: String?, + qrBitmap: Bitmap?, + shareExpiry: ShareExpiry, + isLoading: Boolean, + onExpiryClick: () -> Unit, + onGenerate: () -> Unit, + onCopyCode: (String) -> Unit, + onCopyLink: (String) -> Unit, + onDeleteShare: (ActiveShare) -> Unit +) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Current Inventory Stats + Card( + colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color(0xFF1E293B)), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface(shape = CircleShape, color = androidx.compose.ui.graphics.Color(0xFF8B5CF6).copy(alpha = 0.2f), modifier = Modifier.size(48.dp)) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Default.Upload, null, tint = androidx.compose.ui.graphics.Color(0xFF8B5CF6), modifier = Modifier.size(24.dp)) + } + } + Column { + Text("Your Inventory", color = androidx.compose.ui.graphics.Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Text("Ready to share with others", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.6f), fontSize = 13.sp) + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatPill("Garages", garages.size.toString(), androidx.compose.ui.graphics.Color(0xFF3B82F6)) + StatPill("Items", items.size.toString(), androidx.compose.ui.graphics.Color(0xFF8B5CF6)) + StatPill("Logs", history.size.toString(), androidx.compose.ui.graphics.Color(0xFFF59E0B)) + } + } + } + + // Expiry Selection + Card( + colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color(0xFF1E293B)), + shape = RoundedCornerShape(20.dp), + modifier = Modifier.clickable { onExpiryClick() }, + elevation = CardDefaults.cardElevation(4.dp) + ) { + Row(modifier = Modifier.fillMaxWidth().padding(20.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Schedule, null, tint = androidx.compose.ui.graphics.Color(0xFFF59E0B), modifier = Modifier.size(24.dp)) + Column { + Text("Expires In", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.6f), fontSize = 13.sp) + Text(shareExpiry.label, color = androidx.compose.ui.graphics.Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold) + } + } + Icon(Icons.Default.ChevronRight, null, tint = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.3f), modifier = Modifier.size(28.dp)) + } + } + + // Generate Button + Button( + onClick = onGenerate, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors(containerColor = androidx.compose.ui.graphics.Color(0xFF8B5CF6)), + shape = RoundedCornerShape(16.dp), + enabled = !isLoading && items.isNotEmpty() + ) { + Icon(Icons.Default.QrCode2, null, modifier = Modifier.size(24.dp)) + Spacer(Modifier.width(12.dp)) + Text("Generate Share Code", fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + + // Generated Code Display + AnimatedVisibility(visible = generatedCode != null, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically()) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (qrBitmap != null) { + Card( + colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color.White), + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Image(bitmap = qrBitmap.asImageBitmap(), contentDescription = "QR Code", modifier = Modifier.size(220.dp)) + } + } + } + + Card( + colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color(0xFF1E293B)), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Your Share Code", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.6f), fontSize = 13.sp) + Text(generatedCode ?: "", color = androidx.compose.ui.graphics.Color(0xFF8B5CF6), fontSize = 34.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 5.sp) + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = { generatedCode?.let { onCopyCode(it) } }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = androidx.compose.ui.graphics.Color.White), + border = BorderStroke(2.dp, androidx.compose.ui.graphics.Color(0xFF8B5CF6)) + ) { + Icon(Icons.Default.ContentCopy, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("Copy Code", fontWeight = FontWeight.Bold) + } + OutlinedButton( + onClick = { generatedCode?.let { onCopyLink(it) } }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = androidx.compose.ui.graphics.Color.White), + border = BorderStroke(2.dp, androidx.compose.ui.graphics.Color(0xFF3B82F6)) + ) { + Icon(Icons.Default.Link, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("Copy Link", fontWeight = FontWeight.Bold) + } + } + } + } + + // Active Shares List + if (myActiveShares.isNotEmpty()) { + Text( + "Active Shares (${myActiveShares.size})", + color = androidx.compose.ui.graphics.Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 12.dp) + ) + myActiveShares.forEach { share -> + ActiveShareCard( + activeShare = share, + onCopy = { onCopyCode(share.code) }, + onDelete = { onDeleteShare(share) } + ) + } + } + } +} + +@Composable +private fun ActiveShareCard(activeShare: ActiveShare, onCopy: () -> Unit, onDelete: () -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = androidx.compose.ui.graphics.Color(0xFF1E293B)), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(activeShare.name, color = androidx.compose.ui.graphics.Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Text(activeShare.code, color = androidx.compose.ui.graphics.Color(0xFF8B5CF6), fontSize = 20.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text("${activeShare.itemCount} items", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.5f), fontSize = 12.sp) + Text("•", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.3f), fontSize = 12.sp) + Text("Expires ${formatTimestamp(activeShare.expiresAt, "MMM dd, hh:mm a")}", color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.5f), fontSize = 12.sp) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton(onClick = onCopy, modifier = Modifier.size(44.dp)) { + Icon(Icons.Default.ContentCopy, null, tint = androidx.compose.ui.graphics.Color.White, modifier = Modifier.size(22.dp)) + } + IconButton(onClick = onDelete, modifier = Modifier.size(44.dp)) { + Icon(Icons.Default.Delete, null, tint = androidx.compose.ui.graphics.Color(0xFFEF4444), modifier = Modifier.size(22.dp)) + } + } + } + } +} + + +// --- UTILITY AND DATA PERSISTENCE --- + +private fun generateShareCode(): String { + val chars = ('A'..'Z').toList() + val nums = ('0'..'9').toList() + val part1 = (1..3).map { chars.random() }.joinToString("") + val part2 = (1..3).map { nums.random() }.joinToString("") + return "$part1-$part2" +} + +private fun generateQRCode(text: String): Bitmap? { + return try { + val hints = mapOf(EncodeHintType.MARGIN to 1) + val writer = QRCodeWriter() + val bitMatrix = writer.encode(text, BarcodeFormat.QR_CODE, 512, 512, hints) + val width = bitMatrix.width + val height = bitMatrix.height + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + for (x in 0 until width) { + for (y in 0 until height) { + bmp.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE) + } + } + bmp + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +private fun formatTimestamp(timestamp: Long, pattern: String = "MMM dd, yyyy"): String { + val sdf = SimpleDateFormat(pattern, Locale.US) + return try { + "on ${sdf.format(Date(timestamp))}" + } catch (e: Exception) { + "Invalid Date" + } +} + +private fun copyToClipboard(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("share_code", text) + clipboard.setPrimaryClip(clip) +} + + +// Generic file helpers for data persistence +private inline fun saveDataToFile(context: Context, data: T, filename: String) { + val file = File(context.filesDir, filename) + file.writeText(Gson().toJson(data)) +} + +private inline fun loadDataFromFile(context: Context, filename: String): T? { + val file = File(context.filesDir, filename) + return if (file.exists()) { + val json = file.readText() + Gson().fromJson(json, object : TypeToken() {}.type) + } else { + null + } +} + +// Specific implementations for each data type +private fun saveUserProfile(context: Context, profile: UserProfile) = saveDataToFile(context, profile, "user_profile.json") +private fun loadUserProfile(context: Context): UserProfile = loadDataFromFile(context, "user_profile.json") ?: UserProfile() + +private fun saveShareHistory(context: Context, history: List) = saveDataToFile(context, history, "share_history.json") +private fun loadShareHistory(context: Context): List = loadDataFromFile>(context, "share_history.json") ?: emptyList() + +private fun saveSharedGarages(context: Context, garages: List) = saveDataToFile(context, garages, "shared_garages.json") +private fun loadSharedGarages(context: Context): List = loadDataFromFile>(context, "shared_garages.json") ?: emptyList() + +private fun saveMyActiveShares(context: Context, shares: List) = saveDataToFile(context, shares, "my_active_shares.json") +private fun loadMyActiveShares(context: Context): List = loadDataFromFile>(context, "my_active_shares.json") ?: emptyList() \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/services/AIService.kt b/app/src/main/java/com/samuel/inventorymanager/services/AIService.kt new file mode 100644 index 0000000..923a20f --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/services/AIService.kt @@ -0,0 +1,890 @@ +package com.samuel.inventorymanager.services + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.label.ImageLabeling +import com.google.mlkit.vision.label.defaults.ImageLabelerOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import kotlin.math.abs + +class AIService(private val context: Context) { + + companion object { + private const val TAG = "AIService" + private const val LABEL_CONFIDENCE_THRESHOLD = 0.65f + + // Non-English words to aggressively filter + private val NON_ENGLISH_INDICATORS = setOf( + "capacidad", "almacenamiento", "memoria", "unidad", "disco", "tarjeta", + "portátil", "externo", "rápido", "alta", "velocidad", "garantía", + "capacité", "stockage", "mémoire", "unité", "disque", "carte", + "portable", "externe", "rapide", "haute", "vitesse", "garantie", + "clé", "lecteur", "disponible", "producto", "produit" + ) + + private val ENGLISH_TECH_WORDS = setOf( + "storage", "memory", "drive", "flash", "card", "usb", "external", + "portable", "capacity", "speed", "ultra", "pro", "extreme", "plus", + "stick", "disk", "solid", "state", "high", "fast", "warranty", + "cruzer", "glide", "blade", "ultra", "fit", "edge", "force" + ) + + // Noise words that shouldn't be in product names + private val NOISE_WORDS = setOf( + "the", "with", "for", "and", "from", "this", "that", "these", + "capacity", "storage", "available", "made", "china", "usa", + "high", "speed", "fast", "quality", "compatible", "warranty" + ) + } + + private val labeler by lazy { + ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS) + } + + private val ocrService = OCRService(context) + private val brandDatabase = BrandDatabase() + private val productTypeClassifier = ProductTypeClassifier() + private val contextualExtractor = ContextualExtractor() + private val languageDetector = LanguageDetector() + private val logoDetector = LogoDetector() + private val nameBuilder = IntelligentNameBuilder() + + suspend fun analyzeItemFromBitmap(bitmap: Bitmap): AIAnalysisResult = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "=== Starting Advanced AI Analysis ===") + + val tempUri = saveBitmapToTempUri(bitmap) + + // Step 1: OCR + val ocrResult = try { + ocrService.performOCR(tempUri) + } catch (e: Exception) { + Log.w(TAG, "OCR failed", e) + OCRResult("", 0.0, "ML Kit") + } + + Log.d(TAG, "Raw OCR Text:\n${ocrResult.text}") + + // Step 2: Image Labeling + val image = InputImage.fromBitmap(bitmap, 0) + val rawLabels = try { + labeler.process(image).await() + } catch (e: Exception) { + Log.w(TAG, "Image labeling failed", e) + emptyList() + } + + val labels = rawLabels + .filter { it.confidence >= LABEL_CONFIDENCE_THRESHOLD } + .map { LabelInfo(it.text, it.confidence) } + + Log.d(TAG, "Image Labels: ${labels.joinToString { "${it.label}(${it.confidence})" }}") + + // Step 3: Logo Detection + val detectedLogos = logoDetector.detectLogos(ocrResult.text, labels) + Log.d(TAG, "Detected Logos: $detectedLogos") + + // Step 4: Filter English text + val filteredText = filterEnglishText(ocrResult.text) + Log.d(TAG, "Filtered English Text:\n$filteredText") + + // Step 5: Preprocess + val analysisContext = AnalysisContext( + rawText = filteredText, + imageLabels = labels, + lines = preprocessText(filteredText), + detectedLogos = detectedLogos + ) + + Log.d(TAG, "Preprocessed ${analysisContext.lines.size} semantic units") + + // Step 6: Extract product information + val productInfo = extractProductInformation(analysisContext) + Log.d(TAG, "=== Extracted Product Info ===") + Log.d(TAG, "Brand: ${productInfo.brand}") + Log.d(TAG, "Product Line: ${productInfo.productLine}") + Log.d(TAG, "Model: ${productInfo.modelNumber}") + Log.d(TAG, "Capacity: ${productInfo.capacity}") + Log.d(TAG, "Category: ${productInfo.category}") + + // Step 7: Build final result + val result = buildStructuredResult(productInfo, analysisContext) + Log.d(TAG, "=== Final Result ===") + Log.d(TAG, "Name: ${result.itemName}") + Log.d(TAG, "Confidence: ${result.confidence}") + + cleanupTempFiles() + result + + } catch (e: Exception) { + Log.e(TAG, "AI analysis failed", e) + throw Exception("AI analysis failed: ${e.message}") + } + } + + private fun buildStructuredResult( + productInfo: ProductInfo, + context: AnalysisContext + ): AIAnalysisResult { + return AIAnalysisResult( + itemName = productInfo.name, + modelNumber = productInfo.modelNumber, + description = productInfo.description, + condition = productInfo.condition, + sizeCategory = null, + estimatedPrice = productInfo.price, + dimensions = productInfo.dimensions, + rawText = context.rawText.ifBlank { null }, + confidence = calculateOverallConfidence(productInfo, context) + ) + } + + private fun filterEnglishText(rawText: String): String { + val lines = rawText.lines().map { it.trim() }.filter { it.isNotBlank() } + val scoredLines = mutableListOf>() + + for (line in lines) { + val score = languageDetector.scoreEnglishLikelihood(line) + if (score >= 0.3) { + scoredLines.add(line to score) + Log.d(TAG, "✓ Line: '$line' | Score: $score") + } else { + Log.d(TAG, "✗ Filtered: '$line' | Score: $score") + } + } + + val englishLines = scoredLines + .sortedByDescending { it.second } + .map { it.first } + + return if (englishLines.isNotEmpty()) { + englishLines.joinToString("\n") + } else { + Log.w(TAG, "No English text detected, using original") + rawText + } + } + + private fun preprocessText(rawText: String): List { + val lines = rawText.lines().map { it.trim() }.filter { it.isNotBlank() } + val semanticUnits = mutableListOf() + var position = 0 + + for (line in lines) { + val cleaned = cleanLine(line) + if (cleaned.isEmpty()) continue + + val quality = assessLineQuality(cleaned) + if (quality.isUseful) { + semanticUnits.add( + SemanticUnit( + text = cleaned, + originalText = line, + position = position++, + quality = quality, + tokens = tokenize(cleaned), + type = classifyLineType(cleaned) + ) + ) + } + } + + return groupRelatedUnits(semanticUnits) + } + + private fun cleanLine(line: String): String { + var cleaned = line + + // Filter lines with too many accents + val accentedCount = cleaned.count { it in "áéíóúñüàèìòùâêîôûëïöçÁÉÍÓÚÑÜÀÈÌÒÙÂÊÎÔÛËÏÖÇ" } + if (accentedCount > 2) { + return "" + } + + cleaned = cleaned.replace(Regex("\\s+"), " ").trim() + + // Noise patterns + val noisePatterns = listOf( + Regex("^(MSIP|REM|FCC|CE|UL|ETL|ROHS|WEEE|CE\\d+|RECYCLABLE).*", RegexOption.IGNORE_CASE), + Regex(".*\\b(COMPLIANCE|CERTIFIED|APPROVED|REGULATION)\\s+\\d+.*", RegexOption.IGNORE_CASE), + Regex("^\\d{13,}$"), + Regex("^[A-Z]{10,}$"), + Regex("^[0-9A-F]{16,}$"), + Regex("^www\\..*", RegexOption.IGNORE_CASE), + Regex(".*@.*\\.com", RegexOption.IGNORE_CASE) + ) + + for (pattern in noisePatterns) { + if (pattern.matches(cleaned)) return "" + } + + // Filter obvious non-English + val lowerCleaned = cleaned.lowercase() + val hasNonEnglish = NON_ENGLISH_INDICATORS.any { lowerCleaned.contains(it) } + val hasEnglish = ENGLISH_TECH_WORDS.any { lowerCleaned.contains(it) } + + if (hasNonEnglish && !hasEnglish) { + return "" + } + + return cleaned + } + + private fun assessLineQuality(line: String): LineQuality { + var score = 50.0 + + // Length scoring + score += when (line.length) { + in 3..5 -> -20.0 + in 6..10 -> 5.0 + in 11..40 -> 25.0 + in 41..80 -> 15.0 + else -> -25.0 + } + + // Character composition + val letters = line.count { it.isLetter() } + val numbers = line.count { it.isDigit() } + val total = letters + numbers + + if (total > 0) { + val letterRatio = letters.toDouble() / total + score += when { + letterRatio in 0.4..0.8 -> 25.0 + letterRatio in 0.2..0.9 -> 10.0 + else -> -15.0 + } + } + + // Word count + val words = line.split(Regex("\\s+")).filter { it.length > 1 } + score += when (words.size) { + 0, 1 -> -10.0 + 2, 3 -> 15.0 + 4, 5, 6 -> 25.0 + else -> 10.0 + } + + // Meaningful words + if (words.any { it.length >= 4 }) score += 20.0 + + // Special characters + val specialChars = line.count { !it.isLetterOrDigit() && !it.isWhitespace() } + val specialRatio = if (line.isNotEmpty()) specialChars.toDouble() / line.length else 0.0 + if (specialRatio > 0.4) score -= 40.0 + + // Product keywords + val productKeywords = listOf( + "GB", "TB", "MB", "USB", "Drive", "Flash", "Storage", "Memory", + "External", "Portable", "SSD", "HDD", "Card", "Stick", "Pro", + "Ultra", "Elite", "Max", "Plus", "Extreme", "Cruzer", "Glide", + "Blade", "Edge", "Fit", "Force", "Evo", "Premium" + ) + + val keywordMatches = productKeywords.count { line.contains(it, ignoreCase = true) } + score += keywordMatches * 20.0 + + // English scoring + val englishScore = languageDetector.scoreEnglishLikelihood(line) + score += (englishScore * 35.0) + + // Penalty for accents + val accentedChars = line.count { it in "áéíóúñüàèìòùâêîôûëïöç" } + score -= (accentedChars * 20.0) + + return LineQuality(score, score > 35.0, (score / 100.0).coerceIn(0.0, 1.0)) + } + + private fun tokenize(text: String): List { + val words = text.split(Regex("\\s+")).filter { it.isNotBlank() } + return words.mapIndexed { index, word -> + Token(word, index, classifyToken(word)) + } + } + + private fun classifyToken(word: String) = when { + word.matches(Regex("\\d+(?:\\.\\d+)?\\s*(?:TB|GB|MB|PB)", RegexOption.IGNORE_CASE)) -> TokenType.CAPACITY + word.matches(Regex("\\d+")) -> TokenType.NUMBER + word.matches(Regex("[A-Z][a-z]+")) -> TokenType.PROPER_NOUN + word.matches(Regex("[A-Z]{2,}")) -> TokenType.ACRONYM + word.matches(Regex("[A-Z0-9\\-]+")) -> TokenType.MODEL_CODE + word.lowercase() in listOf("usb", "ssd", "hdd", "drive", "flash", "storage", "card") -> TokenType.PRODUCT_TYPE + else -> TokenType.WORD + } + + private fun classifyLineType(line: String): LineType = when { + line.contains(Regex("\\d+\\s*(?:GB|TB|MB)", RegexOption.IGNORE_CASE)) -> LineType.CAPACITY_INFO + line.matches(Regex(".*(?:Model|SKU|Part|P/N|MPN).*", RegexOption.IGNORE_CASE)) -> LineType.MODEL_INFO + line.split("\\s+".toRegex()).size <= 4 && line.any { it.isUpperCase() } -> LineType.BRAND_OR_SERIES + line.contains("$") || line.contains(Regex("\\d+\\.\\d{2}")) -> LineType.PRICE_INFO + line.contains(Regex("\\d+\\s*x\\s*\\d+", RegexOption.IGNORE_CASE)) -> LineType.DIMENSIONS + else -> LineType.DESCRIPTIVE + } + + private fun groupRelatedUnits(units: List): List { + val grouped = mutableListOf() + var i = 0 + + while (i < units.size) { + val current = units[i] + if (i + 1 < units.size) { + val next = units[i + 1] + if (shouldMerge(current, next)) { + grouped.add( + current.copy( + text = "${current.text} ${next.text}", + tokens = current.tokens + next.tokens + ) + ) + i += 2 + continue + } + } + grouped.add(current) + i++ + } + return grouped + } + + private fun shouldMerge(a: SemanticUnit, b: SemanticUnit): Boolean { + if (abs(a.position - b.position) > 1) return false + if (a.type == LineType.BRAND_OR_SERIES && b.type == LineType.MODEL_INFO) return true + if (a.type == LineType.BRAND_OR_SERIES && b.type == LineType.CAPACITY_INFO) return true + if (a.text.length < 20 && b.text.length < 20 && + a.type == LineType.DESCRIPTIVE && b.type == LineType.DESCRIPTIVE + ) return true + return false + } + + private fun extractProductInformation(context: AnalysisContext): ProductInfo { + val info = ProductInfo() + + val goodLines = context.lines.filter { it.quality.score > 45 } + + if (context.rawText.length < 10 || goodLines.isEmpty()) { + info.name = "Unknown Item" + info.description = "Not enough readable text" + return info + } + + // Extract in priority order + info.category = productTypeClassifier.classify(context) + info.brand = brandDatabase.findBrand(context, info.category, context.detectedLogos) + info.capacity = contextualExtractor.extractCapacity(context) + info.productLine = contextualExtractor.extractProductLine(context, info.brand) + info.modelNumber = contextualExtractor.extractModelNumber(context, info.brand, info.capacity, info.productLine) + info.name = nameBuilder.buildName(info, context) + info.description = generateSmartDescription(info, context) + info.condition = contextualExtractor.extractCondition(context) + info.price = contextualExtractor.extractPrice(context) + info.dimensions = contextualExtractor.extractDimensions(context) + + return info + } + + private fun generateSmartDescription(info: ProductInfo, context: AnalysisContext): String { + val parts = mutableListOf() + + info.category?.let { parts.add(it) } + info.capacity?.let { parts.add("Capacity: $it") } + + context.lines + .filter { it.quality.score > 55 } + .filter { it.type == LineType.DESCRIPTIVE } + .filter { line -> + !line.text.contains(info.brand ?: "", ignoreCase = true) && + !line.text.contains(info.productLine ?: "", ignoreCase = true) + } + .take(2) + .forEach { parts.add(it.text) } + + return parts.joinToString(" • ").ifBlank { "No description available" } + } + + private fun calculateOverallConfidence(info: ProductInfo, context: AnalysisContext): Double { + var confidence = 0.0 + + if (info.brand != null) confidence += 30.0 + if (info.productLine != null) confidence += 20.0 + if (info.modelNumber != null) confidence += 15.0 + if (info.capacity != null) confidence += 20.0 + if (info.category != null) confidence += 10.0 + + val avgQuality = context.lines.mapNotNull { it.quality.score }.average() + if (!avgQuality.isNaN()) { + confidence += (avgQuality * 0.05).coerceAtMost(5.0) + } + + return (confidence / 100.0).coerceIn(0.0, 1.0) + } + + private fun cleanupTempFiles() { + try { + context.cacheDir.listFiles { it.name.startsWith("temp_") && it.name.endsWith(".jpg") } + ?.forEach { it.delete() } + } catch (_: Exception) { + // Ignore + } + } + + private fun saveBitmapToTempUri(bitmap: Bitmap): Uri { + val file = java.io.File(context.cacheDir, "temp_${System.currentTimeMillis()}.jpg") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) } + return Uri.fromFile(file) + } + + // === INNER CLASSES === + + inner class LanguageDetector { + fun scoreEnglishLikelihood(text: String): Double { + var score = 0.5 + val lowerText = text.lowercase() + val words = text.split(Regex("\\s+")).filter { it.length > 2 } + + // English words + val englishCount = words.count { word -> + ENGLISH_TECH_WORDS.any { word.lowercase().contains(it) } + } + score += englishCount * 0.20 + + // Non-English words + val nonEnglishCount = words.count { word -> + NON_ENGLISH_INDICATORS.any { word.lowercase().contains(it) } + } + score -= nonEnglishCount * 0.30 + + // Accents + val accentedChars = text.count { it in "áéíóúñüàèìòùâêîôûëïöç" } + val accentRatio = if (text.length > 0) accentedChars.toDouble() / text.length else 0.0 + score -= accentRatio * 3.0 + + // English patterns + if (lowerText.matches(Regex(".*\\b(the|and|with|for|from|this|that)\\b.*"))) { + score += 0.25 + } + + // Spanish/French patterns + if (lowerText.matches(Regex(".*\\b(de|el|la|le|du|des|une?|los|las)\\b.*"))) { + score -= 0.35 + } + + // Brands + val allBrands = brandDatabase.getAllBrands() + if (allBrands.any { lowerText.contains(it.lowercase()) }) { + score += 0.25 + } + + return score.coerceIn(0.0, 1.0) + } + } + + inner class LogoDetector { + private val logoPatterns = mapOf( + "SanDisk" to listOf("sandisk", "san disk", "SANDISK"), + "Samsung" to listOf("samsung", "SAMSUNG"), + "Western Digital" to listOf("western digital", "wd", "WD", "westerndigital"), + "Kingston" to listOf("kingston", "KINGSTON"), + "Seagate" to listOf("seagate", "SEAGATE"), + "Crucial" to listOf("crucial", "CRUCIAL"), + "Corsair" to listOf("corsair", "CORSAIR"), + "PNY" to listOf("pny", "PNY"), + "Lexar" to listOf("lexar", "LEXAR"), + "Transcend" to listOf("transcend", "TRANSCEND") + ) + + fun detectLogos(text: String, labels: List): List { + val detected = mutableSetOf() + val lowerText = text.lowercase() + + // Text-based detection + for ((brand, patterns) in logoPatterns) { + if (patterns.any { lowerText.contains(it.lowercase()) }) { + detected.add(brand) + Log.d(TAG, "Logo detected in text: $brand") + } + } + + // Image label detection + for (label in labels) { + for ((brand, patterns) in logoPatterns) { + if (patterns.any { label.label.contains(it, ignoreCase = true) }) { + detected.add(brand) + Log.d(TAG, "Logo detected in image: $brand") + } + } + } + + return detected.toList() + } + } + + inner class IntelligentNameBuilder { + fun buildName(info: ProductInfo, context: AnalysisContext): String { + val parts = mutableListOf() + + // Priority 1: Brand (from logo or text) + info.brand?.let { brand -> + parts.add(brand) + Log.d(TAG, "Name part 1: Brand = $brand") + } + + // Priority 2: Product Line (Cruzer, Ultra, etc.) + info.productLine?.let { line -> + if (!parts.any { it.equals(line, ignoreCase = true) }) { + parts.add(line) + Log.d(TAG, "Name part 2: Product Line = $line") + } + } + + // Priority 3: Capacity + info.capacity?.let { capacity -> + parts.add(capacity) + Log.d(TAG, "Name part 3: Capacity = $capacity") + } + + // Priority 4: Category (if we don't have enough info) + if (parts.size < 2) { + info.category?.let { category -> + if (!parts.any { it.equals(category, ignoreCase = true) }) { + parts.add(category) + Log.d(TAG, "Name part 4: Category = $category") + } + } + } + + // Fallback: use highest quality line + if (parts.isEmpty()) { + context.lines + .filter { it.quality.score > 60 } + .maxByOrNull { it.quality.score } + ?.let { + val cleaned = cleanNameText(it.text) + if (cleaned.isNotBlank()) { + parts.add(cleaned) + Log.d(TAG, "Name fallback: $cleaned") + } + } + } + + val finalName = parts.joinToString(" ").ifBlank { "Unknown Item" } + Log.d(TAG, "=== Final Name: $finalName ===") + return finalName + } + + private fun cleanNameText(text: String): String { + var cleaned = text + + // Remove noise words + val words = cleaned.split(Regex("\\s+")) + val filtered = words.filter { word -> + !NOISE_WORDS.contains(word.lowercase()) && + word.length > 1 + } + + cleaned = filtered.joinToString(" ").take(60) + return cleaned.trim() + } + } + + inner class BrandDatabase { + private val brands = mapOf( + "storage" to listOf( + "SanDisk", "Samsung", "Western Digital", "WD", "Seagate", + "Kingston", "Crucial", "Intel", "Corsair", "PNY", "Transcend", + "Lexar", "ADATA", "G.SKILL", "TeamGroup", "Patriot", "Mushkin", + "Silicon Power", "Toshiba", "Verbatim", "Sony" + ), + "general" to listOf( + "Apple", "Sony", "LG", "HP", "Dell", "Lenovo", "ASUS", "Acer", + "Microsoft", "Google", "Amazon", "Logitech", "Razer", "Anker" + ) + ) + + fun getAllBrands(): List = brands.values.flatten() + + fun findBrand(context: AnalysisContext, category: String?, detectedLogos: List): String? { + // Priority 1: Detected logos + if (detectedLogos.isNotEmpty()) { + Log.d(TAG, "Brand from logo: ${detectedLogos.first()}") + return detectedLogos.first() + } + + val allBrands = getAllBrands() + val categoryBrands = category?.let { brands[it.lowercase()] } ?: emptyList() + + // Priority 2: Category-specific brands in high-quality lines + for (unit in context.lines.sortedByDescending { it.quality.score }.take(7)) { + for (brand in categoryBrands) { + if (unit.text.contains(brand, ignoreCase = true)) { + Log.d(TAG, "Brand from category search: $brand") + return brand + } + } + } + + // Priority 3: Any brand in high-quality lines + for (unit in context.lines.sortedByDescending { it.quality.score }.take(7)) { + for (brand in allBrands) { + if (unit.text.contains(brand, ignoreCase = true)) { + Log.d(TAG, "Brand from general search: $brand") + return brand + } + } + } + + // Priority 4: Image labels + for (label in context.imageLabels.filter { it.confidence > 0.6f }) { + for (brand in allBrands) { + if (label.label.contains(brand, ignoreCase = true)) { + Log.d(TAG, "Brand from image label: $brand") + return brand + } + } + } + + return null + } + } + + inner class ProductTypeClassifier { + private val typeKeywords = mapOf( + "USB Flash Drive" to listOf("usb", "flash", "drive", "stick", "pendrive", "thumb", "cruzer", "glide", "blade"), + "SSD" to listOf("ssd", "solid state", "nvme", "sata"), + "Hard Drive" to listOf("hdd", "hard drive", "disk drive", "mechanical"), + "Memory Card" to listOf("sd card", "microsd", "memory card", "flash card", "tf card"), + "External Storage" to listOf("external", "portable storage", "portable drive"), + "Power Supply" to listOf("power supply", "psu", "switching", "adapter", "charger", "ac/dc") + ) + + fun classify(context: AnalysisContext): String? { + val scores = mutableMapOf() + + for ((type, keywords) in typeKeywords) { + var score = 0.0 + + for (unit in context.lines) { + for (keyword in keywords) { + if (unit.text.contains(keyword, ignoreCase = true)) { + score += (unit.quality.score / 100.0) * 1.5 + } + } + } + + for (label in context.imageLabels) { + for (keyword in keywords) { + if (label.label.contains(keyword, ignoreCase = true)) { + score += label.confidence * 2.5 + } + } + } + + scores[type] = score + } + + val best = scores.filter { it.value > 0.6 }.maxByOrNull { it.value } + Log.d(TAG, "Category scores: $scores") + Log.d(TAG, "Selected category: ${best?.key}") + return best?.key + } + } + + inner class ContextualExtractor { + + fun extractCapacity(context: AnalysisContext): String? { + val regex = Regex("(\\d+(?:\\.\\d+)?\\s*(?:TB|GB|MB|PB))\\b", RegexOption.IGNORE_CASE) + val candidates = mutableListOf>() + + for (unit in context.lines) { + regex.findAll(unit.text).forEach { + val capacity = it.value.trim().uppercase() + candidates.add(capacity to unit.quality.score) + } + } + + val best = candidates.maxByOrNull { it.second }?.first + Log.d(TAG, "Extracted capacity: $best from ${candidates.size} candidates") + return best + } + + fun extractProductLine(context: AnalysisContext, brand: String?): String? { + // SanDisk product lines + val sandiskLines = listOf("Cruzer", "Ultra", "Extreme", "Ultra Fit", "Ultra Flair", "Glide", + "Blade", "Switch", "Pop", "Edge", "Force", "Dual Drive", "iXpand") + + // Other brand lines + val genericLines = listOf("Ultra", "Pro", "Extreme", "Evo", "Plus", "Max", "Elite", + "Gaming", "Premium", "Performance", "Essential", "Value", "Blue", "Black", "Red") + + val searchLines = if (brand?.equals("SanDisk", ignoreCase = true) == true) { + sandiskLines + genericLines + } else { + genericLines + } + + // First: Look for exact product line matches in high-quality lines + for (unit in context.lines.sortedByDescending { it.quality.score }.take(8)) { + for (line in searchLines) { + // Check if the product line appears as a distinct word + val pattern = Regex("\\b${Regex.escape(line)}\\b", RegexOption.IGNORE_CASE) + if (pattern.containsMatchIn(unit.text)) { + Log.d(TAG, "Found product line: $line in '${unit.text}'") + return line + } + } + } + + // Second: Check for multi-word product lines (like "Ultra Fit") + for (unit in context.lines.sortedByDescending { it.quality.score }.take(8)) { + val multiWordLines = searchLines.filter { it.contains(" ") } + for (line in multiWordLines) { + if (unit.text.contains(line, ignoreCase = true)) { + Log.d(TAG, "Found multi-word product line: $line") + return line + } + } + } + + return null + } + + fun extractModelNumber(context: AnalysisContext, brand: String?, capacity: String?, productLine: String?): String? { + val patterns = listOf( + Regex("(?:Model|SKU|Part|P/N|MPN)[:\\s]+([A-Z0-9\\-]{4,20})", RegexOption.IGNORE_CASE), + Regex("\\b([A-Z]{2,4}\\d{3,}[A-Z0-9\\-]*)\\b"), + Regex("\\b(\\d{3,}[A-Z]{2,}[A-Z0-9\\-]*)\\b"), + brand?.let { Regex("${Regex.escape(it)}[\\s-]?([A-Z0-9\\-]{4,15})\\b", RegexOption.IGNORE_CASE) } + ).filterNotNull() + + for (unit in context.lines.sortedByDescending { it.quality.score }.take(10)) { + // Skip very long descriptive lines + if (unit.text.length > 35 && unit.type == LineType.DESCRIPTIVE) continue + + for (pattern in patterns) { + pattern.findAll(unit.text).forEach { match -> + val model = match.groupValues.getOrNull(1)?.trim() ?: match.value.trim() + + // Validation checks + if (model.length !in 4..25) return@forEach + + // Don't confuse capacity with model number + if (capacity != null && model.contains(capacity, ignoreCase = true)) return@forEach + + // Don't confuse product line with model + if (productLine != null && model.equals(productLine, ignoreCase = true)) return@forEach + + if (model.isBlank()) return@forEach + if (!model.any { it.isLetterOrDigit() }) return@forEach + + // Must have mix of letters and numbers + val letters = model.count { it.isLetter() } + val digits = model.count { it.isDigit() } + if (letters < 2 || digits < 1) return@forEach + + Log.d(TAG, "Found model number: $model") + return model + } + } + } + return null + } + + fun extractCondition(context: AnalysisContext): String? { + val conditions = mapOf( + "New" to listOf("brand new", "new in box", "sealed", "unopened", "nib"), + "Like New" to listOf("like new", "mint", "excellent", "pristine", "barely used"), + "Good" to listOf("good", "used", "working", "functional"), + "Fair" to listOf("fair", "worn", "some wear"), + "Poor" to listOf("poor", "damaged", "broken", "for parts", "not working") + ) + + val text = context.rawText.lowercase() + for ((condition, keywords) in conditions) { + if (keywords.any { text.contains(it) }) { + Log.d(TAG, "Found condition: $condition") + return condition + } + } + return null + } + + fun extractPrice(context: AnalysisContext): Double? { + val priceRegex = Regex("\\$\\s*([0-9,]+(?:\\.\\d{2})?)") + val match = priceRegex.find(context.rawText) + val priceString = match?.groupValues?.get(1)?.replace(",", "") + val price = priceString?.toDoubleOrNull() + return if (price != null && price in 0.01..999999.99) { + Log.d(TAG, "Found price: $price") + price + } else null + } + + fun extractDimensions(context: AnalysisContext): String? { + val dimensionsRegex = Regex( + "(\\d+(?:\\.\\d+)?)\\s*[xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[xX×]\\s*(\\d+(?:\\.\\d+)?))?\\s*(in|inch|cm|mm)?", + RegexOption.IGNORE_CASE + ) + val result = dimensionsRegex.find(context.rawText)?.value?.trim() + if (result != null) { + Log.d(TAG, "Found dimensions: $result") + } + return result + } + } + + // Data classes + data class AIAnalysisResult( + val itemName: String?, + val modelNumber: String?, + val description: String?, + val condition: String?, + val sizeCategory: String?, + val estimatedPrice: Double?, + val dimensions: String?, + val rawText: String?, + val confidence: Double + ) + + data class AnalysisContext( + val rawText: String, + val imageLabels: List, + val lines: List, + val detectedLogos: List = emptyList() + ) + + data class LabelInfo(val label: String, val confidence: Float) + + data class SemanticUnit( + val text: String, + val originalText: String, + val position: Int, + val quality: LineQuality, + val tokens: List, + val type: LineType + ) + + data class LineQuality(val score: Double, val isUseful: Boolean, val confidence: Double) + + data class Token(val text: String, val position: Int, val type: TokenType) + + enum class TokenType { CAPACITY, NUMBER, PROPER_NOUN, ACRONYM, MODEL_CODE, PRODUCT_TYPE, WORD } + + enum class LineType { BRAND_OR_SERIES, CAPACITY_INFO, MODEL_INFO, PRICE_INFO, DIMENSIONS, DESCRIPTIVE } + + data class ProductInfo( + var name: String = "", + var brand: String? = null, + var modelNumber: String? = null, + var productLine: String? = null, + var capacity: String? = null, + var category: String? = null, + var description: String = "", + var condition: String? = null, + var price: Double? = null, + var dimensions: String? = null + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/services/AIServicess.kt b/app/src/main/java/com/samuel/inventorymanager/services/AIServicess.kt new file mode 100644 index 0000000..7368cec --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/services/AIServicess.kt @@ -0,0 +1,177 @@ +//package com.samuel.inventorymanager.services +// +//import android.content.Context +//import android.graphics.Bitmap +//import android.util.Base64 +//import android.util.Log +//import com.samuel.inventorymanager.data.AISettings +//import com.samuel.inventorymanager.screens.AIAnalysisResult +//import kotlinx.coroutines.Dispatchers +//import kotlinx.coroutines.withContext +//import org.json.JSONObject +//import java.io.ByteArrayOutputStream +//import java.net.HttpURLConnection +//import java.net.URL +// +//class AIService(private val context: Context) { +// +// suspend fun analyzeItemFromBitmap( +// bitmap: Bitmap, +// aiSettings: AISettings = AISettings() +// ): AIAnalysisResult = withContext(Dispatchers.IO) { +// try { +// // Check if API key exists +// if (aiSettings.anthropicApiKey.isNullOrBlank()) { +// throw Exception("AI API Key is missing. Please configure it in Settings.") +// } +// +// // 1. Convert bitmap to base64 +// val base64Image = bitmapToBase64(bitmap) +// +// // 2. Call Claude API +// val response = callClaudeAPI(base64Image, aiSettings) +// +// // 3. Parse response +// parseAIResponse(response) +// } catch (e: Exception) { +// Log.e("AIService", "Analysis failed", e) +// // Return an empty result with the error in description instead of crashing +// AIAnalysisResult( +// description = "AI Analysis failed: ${e.message}", +// rawText = "Error during processing." +// ) +// } +// } +// +// private suspend fun callClaudeAPI( +// base64Image: String?, +// aiSettings: AISettings, +// customPrompt: String? = null +// ): String = withContext(Dispatchers.IO) { +// val url = URL("https://api.anthropic.com/v1/messages") +// val connection = url.openConnection() as HttpURLConnection +// +// try { +// connection.requestMethod = "POST" +// connection.setRequestProperty("Content-Type", "application/json") +// connection.setRequestProperty("x-api-key", aiSettings.anthropicApiKey) +// connection.setRequestProperty("anthropic-version", "2023-06-01") +// connection.doOutput = true +// +// val prompt = customPrompt ?: buildAnalysisPrompt() +// val requestBody = buildJSONRequest(prompt, base64Image) +// +// connection.outputStream.use { it.write(requestBody.toByteArray()) } +// +// val responseCode = connection.responseCode +// if (responseCode == 200) { +// connection.inputStream.bufferedReader().use { it.readText() } +// } else { +// val error = connection.errorStream?.bufferedReader()?.use { it.readText() } +// throw Exception("API Error ($responseCode): $error") +// } +// } finally { +// connection.disconnect() +// } +// } +// +// private fun buildAnalysisPrompt(): String = """ +// Analyze this item image and extract the following information in JSON format: +// { +// "itemName": "The main product name", +// "modelNumber": "Model/SKU if visible", +// "description": "Brief description (2-3 sentences)", +// "condition": "New/Like New/Good/Fair/Poor based on appearance", +// "sizeCategory": "Small/Medium/Large/Extra Large", +// "estimatedPrice": numeric value in USD (just the number), +// "dimensions": "Estimated dimensions (e.g. 10x10x5 inches) if visual cues exist", +// "detectedText": "Any text visible in the image" +// } +// Be specific. If information is not clearly visible, use null. +// Respond ONLY with valid JSON. +// """.trimIndent() +// +// private fun buildJSONRequest( +// prompt: String, +// base64Image: String? +// ): String { +// val contentParts = mutableListOf() +// +// if (base64Image != null) { +// // Image block +// contentParts.add(""" +// { +// "type": "image", +// "source": { +// "type": "base64", +// "media_type": "image/jpeg", +// "data": "$base64Image" +// } +// } +// """.trimIndent()) +// } +// +// // Text block (Prompt) +// contentParts.add(""" +// { +// "type": "text", +// "text": "$prompt" +// } +// """.trimIndent()) +// +// // Construct final JSON safely +// return """ +// { +// "model": "claude-3-5-sonnet-20241022", +// "max_tokens": 1024, +// "messages": [ +// { +// "role": "user", +// "content": [${contentParts.joinToString(",")}] +// } +// ] +// } +// """.trimIndent() +// } +// +// private fun parseAIResponse(jsonResponse: String): AIAnalysisResult { +// try { +// val rootJson = JSONObject(jsonResponse) +// val contentArray = rootJson.getJSONArray("content") +// val textContent = contentArray.getJSONObject(0).getString("text") +// +// // Clean markdown code blocks if Claude adds them +// val cleanJsonString = textContent +// .replace("```json", "") +// .replace("```", "") +// .trim() +// +// val resultJson = JSONObject(cleanJsonString) +// +// return AIAnalysisResult( +// itemName = resultJson.optString("itemName").takeIf { it.isNotEmpty() }, +// modelNumber = resultJson.optString("modelNumber").takeIf { it.isNotEmpty() }, +// description = resultJson.optString("description").takeIf { it.isNotEmpty() }, +// condition = resultJson.optString("condition").takeIf { it.isNotEmpty() }, +// sizeCategory = resultJson.optString("sizeCategory").takeIf { it.isNotEmpty() }, +// estimatedPrice = resultJson.optDouble("estimatedPrice").takeIf { !it.isNaN() }, +// dimensions = resultJson.optString("dimensions").takeIf { it.isNotEmpty() }, +// // Map AI "detectedText" to our Data Model "rawText" +// rawText = resultJson.optString("detectedText").takeIf { it.isNotEmpty() } +// ) +// } catch (e: Exception) { +// Log.e("AIService", "JSON Parsing failed", e) +// return AIAnalysisResult( +// description = "Could not parse AI response. Raw response: $jsonResponse" +// ) +// } +// } +// +// private fun bitmapToBase64(bitmap: Bitmap): String { +// val outputStream = ByteArrayOutputStream() +// // Compress to 70% quality to save bandwidth/tokens +// bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream) +// val bytes = outputStream.toByteArray() +// return Base64.encodeToString(bytes, Base64.NO_WRAP) +// } +//} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/services/OCRService.kt b/app/src/main/java/com/samuel/inventorymanager/services/OCRService.kt new file mode 100644 index 0000000..334a9bc --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/services/OCRService.kt @@ -0,0 +1,92 @@ +package com.samuel.inventorymanager.services + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import java.io.IOException +import kotlin.math.roundToInt + +// Simplified data model +data class OCRResult( + val text: String, + val confidence: Double = 0.0, + val provider: String = "ML Kit (Free)" +) + +class OCRService(context: Context) { + + // Use Application Context to prevent Activity leaks + private val applicationContext = context.applicationContext + private val TAG = "OCRService" + + // Lazy initialization: Client is only created once when needed, saving memory. + private val recognizer by lazy { + TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + } + + suspend fun performOCR(imageUri: Uri): OCRResult = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Starting OCR for Image: $imageUri") + + // 1. Pre-check: Ensure image loadable + val image: InputImage + try { + image = InputImage.fromFilePath(applicationContext, imageUri) + } catch (e: IOException) { + Log.e(TAG, "Failed to load image from path", e) + return@withContext OCRResult("", 0.0, "Error: Image Not Found") + } + + // 2. Process the image + val result = recognizer.process(image).await() + val rawText = result.text + + // 3. Smart Confidence Calculation + // TextBlock and Line do not have confidence in ML Kit V2, only 'Elements' (words) do. + val allConfidences = result.textBlocks + .flatMap { it.lines } + .flatMap { it.elements } + .mapNotNull { it.confidence } + + // If we found words, calculate average. If not, 0.0 + val finalConfidence = if (allConfidences.isNotEmpty()) { + allConfidences.average() + } else { + // If text was found but no confidence data exists (rare), default to generic high or low + if (rawText.isNotEmpty()) 0.80 else 0.0 + } + + // 4. Round confidence for cleaner UI (e.g. 0.92 instead of 0.9234512) + val roundedConfidence = (finalConfidence * 100.0).roundToInt() / 100.0 + + if (rawText.isBlank()) { + Log.w(TAG, "OCR finished but found no text.") + } else { + Log.i(TAG, "OCR Success. Confidence: $roundedConfidence") + } + + OCRResult( + text = rawText.trim(), // Smart clean: Remove extra whitespace + confidence = roundedConfidence, + provider = "ML Kit (On-Device)" + ) + + } catch (e: Exception) { + Log.e(TAG, "OCR Critical Failure", e) + // Return an empty result or rethrow depending on your app's needs. + // Rethrowing with a clear message allows the ViewModel to handle the UI state "Error" + throw Exception("Failed to scan text: ${e.localizedMessage}") + } + } + + // Backward compatibility: Keeps existing code working while ignoring unused settings + suspend fun performOCR(imageUri: Uri, ocrSettings: Any?): OCRResult { + return performOCR(imageUri) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samuel/inventorymanager/services/Services.kt b/app/src/main/java/com/samuel/inventorymanager/services/Services.kt new file mode 100644 index 0000000..449c533 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/services/Services.kt @@ -0,0 +1,49 @@ +//package com.samuel.inventorymanager.services +// +//import android.content.Context +//import android.net.Uri +//import com.samuel.inventorymanager.screens.AIAnalysisResult +//import kotlinx.coroutines.delay +// +//// --- FAKE AI SERVICE (REPLACE WITH YOUR REAL IMPLEMENTATION) --- +//class AIService(private val context: Context, private val apiKey: String) { +// +// // This is a placeholder. Replace with your actual AI API call. +// suspend fun analyzeItem(prompt: String, imageUri: Uri?): AIAnalysisResult { +// // Simulate network delay +// delay(3000) +// +// // Simulate a successful AI response +// // In a real app, you would make a network request to an AI service +// // and parse the JSON response into the AIAnalysisResult object. +// return AIAnalysisResult( +// itemName = "Vintage Sony Walkman", +// modelNumber = "WM-F45", +// description = "A portable cassette player from the late 1980s. Features AM/FM radio, auto-reverse, and Mega Bass. Requires two AA batteries. Shows minor signs of wear but is fully functional.", +// condition = "Good", +// estimatedPrice = 75.0 +// ) +// } +//} +// +//// --- FAKE OCR SERVICE (REPLACE WITH YOUR REAL IMPLEMENTATION) --- +//class OCRService(private val context: Context) { +// +// data class OCRResult(val text: String, val provider: String) +// +// // This is a placeholder. Replace with a real OCR library (e.g., Google ML Kit). +// suspend fun performOCR(imageUri: Uri): OCRResult { +// // Simulate processing delay +// delay(2000) +// +// // Simulate a successful OCR response +// return OCRResult( +// text = """ +// Sony Corporation +// WM-F45 +// Made in Japan +// """.trimIndent(), +// provider = "Simulated OCR" +// ) +// } +//} \ 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 index fa781d8..645d00b 100644 --- a/app/src/main/java/com/samuel/inventorymanager/ui/theme/Theme.kt +++ b/app/src/main/java/com/samuel/inventorymanager/ui/theme/Theme.kt @@ -1,16 +1,12 @@ 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 @@ -20,18 +16,19 @@ import androidx.compose.ui.unit.sp // COLOR DEFINITIONS // ======================================================================================== +// Base Theme Colors 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 PurpleDark80 = Color(0xFFD0BCFF) +private val PurpleGreyDark80 = Color(0xFFCCC2DC) private val PinkDark80 = Color(0xFFEFB8C8) -// Additional Theme Colors private val LightBackground = Color(0xFFFFFBFE) private val DarkBackground = Color(0xFF1A1A1A) +// Custom Theme Primary Colors private val DraculaPrimary = Color(0xFFBD93F9) private val VampirePrimary = Color(0xFFFF1493) private val OceanPrimary = Color(0xFF00B4D8) @@ -40,6 +37,19 @@ private val SunsetPrimary = Color(0xFFFF6B35) private val CyberpunkPrimary = Color(0xFFFF006E) private val NeonPrimary = Color(0xFF39FF14) +// New Indigo Theme Colors (from second file) +private val IndigoPrimaryLight = Color(0xFF6366F1) +private val EmeraldSecondaryLight = Color(0xFF10B981) +private val RedErrorLight = Color(0xFFEF4444) +private val SlateBackgroundLight = Color(0xFFF8FAFC) +private val WhiteSurfaceLight = Color(0xFFFEFEFE) + +private val IndigoPrimaryDark = Color(0xFF818CF8) // Lighter indigo for dark mode +private val EmeraldSecondaryDark = Color(0xFF34D399) // Lighter emerald for dark mode +private val RedErrorDark = Color(0xFFF87171) // Lighter red for dark mode +private val SlateBackgroundDark = Color(0xFF0F172A) // Dark slate for background +private val SlateSurfaceDark = Color(0xFF1E293B) // Slightly lighter slate for surface + // ======================================================================================== // COLOR SCHEMES // ======================================================================================== @@ -70,6 +80,24 @@ private val DarkColorScheme = darkColorScheme( onSurface = Color.White ) +// New Indigo Theme Scheme +private val IndigoLightScheme = lightColorScheme( + primary = IndigoPrimaryLight, + secondary = EmeraldSecondaryLight, + error = RedErrorLight, + background = SlateBackgroundLight, + surface = WhiteSurfaceLight +) + +private val IndigoDarkScheme = darkColorScheme( + primary = IndigoPrimaryDark, + secondary = EmeraldSecondaryDark, + error = RedErrorDark, + background = SlateBackgroundDark, + surface = SlateSurfaceDark +) + + private val DraculaLightScheme = lightColorScheme( primary = DraculaPrimary, secondary = Color(0xFF8BE9FD), @@ -272,7 +300,7 @@ fun getScaledTypography(scale: Float): Typography { // ======================================================================================== enum class AppThemeType { - LIGHT, DARK, DRACULA, VAMPIRE, OCEAN, FOREST, SUNSET, CYBERPUNK, NEON + LIGHT, DARK, INDIGO, DRACULA, VAMPIRE, OCEAN, FOREST, SUNSET, CYBERPUNK, NEON } // ======================================================================================== @@ -284,25 +312,19 @@ 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 colorScheme = when (themeType) { + AppThemeType.LIGHT -> LightColorScheme + AppThemeType.DARK -> DarkColorScheme + AppThemeType.INDIGO -> if (darkTheme) IndigoDarkScheme else IndigoLightScheme + AppThemeType.DRACULA -> if (darkTheme) DraculaDarkScheme else DraculaLightScheme + AppThemeType.VAMPIRE -> if (darkTheme) VampireDarkScheme else VampireLightScheme + AppThemeType.OCEAN -> if (darkTheme) OceanDarkScheme else OceanLightScheme + AppThemeType.FOREST -> if (darkTheme) ForestDarkScheme else ForestLightScheme + AppThemeType.SUNSET -> if (darkTheme) SunsetDarkScheme else SunsetLightScheme + AppThemeType.CYBERPUNK -> if (darkTheme) CyberpunkDarkScheme else CyberpunkLightScheme + AppThemeType.NEON -> if (darkTheme) NeonDarkScheme else NeonLightScheme } val scaledTypography = getScaledTypography(fontScale) diff --git a/app/src/main/java/com/samuel/inventorymanager/viewmodels/AuthViewModel.kt b/app/src/main/java/com/samuel/inventorymanager/viewmodels/AuthViewModel.kt new file mode 100644 index 0000000..5fc7c52 --- /dev/null +++ b/app/src/main/java/com/samuel/inventorymanager/viewmodels/AuthViewModel.kt @@ -0,0 +1,134 @@ +package com.samuel.inventorymanager.viewmodels + +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.auth.api.identity.BeginSignInRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.auth.api.identity.SignInClient +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GoogleAuthProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +class AuthViewModel : ViewModel() { + + private val _authState = MutableStateFlow(AuthState.Loading) + val authState: StateFlow = _authState + + private val _isFirstLaunch = MutableStateFlow(true) + val isFirstLaunch: StateFlow = _isFirstLaunch + + private lateinit var oneTapClient: SignInClient + private val auth = FirebaseAuth.getInstance() + + fun initialize(context: Context) { + oneTapClient = Identity.getSignInClient(context) + checkAuthState(context) + } + + private fun checkAuthState(context: Context) { + viewModelScope.launch { + try { + // Check if user has completed onboarding + val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + val hasCompletedOnboarding = prefs.getBoolean("completed_onboarding", false) + _isFirstLaunch.value = !hasCompletedOnboarding + + // Check if user is signed in + val currentUser = auth.currentUser + if (currentUser != null && hasCompletedOnboarding) { + _authState.value = AuthState.Authenticated(currentUser.email ?: "User") + } else if (hasCompletedOnboarding) { + _authState.value = AuthState.NotAuthenticated + } else { + _authState.value = AuthState.ShowOnboarding + } + } catch (e: Exception) { + _authState.value = AuthState.Error(e.message ?: "Unknown error") + } + } + } + + fun completeOnboarding(context: Context) { + val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + prefs.edit().putBoolean("completed_onboarding", true).apply() + _isFirstLaunch.value = false + _authState.value = AuthState.NotAuthenticated + } + + fun startGoogleSignIn( + context: Context, + launcher: ActivityResultLauncher + ) { + viewModelScope.launch { + try { + _authState.value = AuthState.Loading + + val signInRequest = BeginSignInRequest.builder() + .setGoogleIdTokenRequestOptions( + BeginSignInRequest.GoogleIdTokenRequestOptions.builder() + .setSupported(true) + // Replace with your actual Web Client ID from Firebase Console + .setServerClientId("604064044455-7rb4vc1arekkbi59999aprp8hamrtkhq.apps.googleusercontent.com") + .setFilterByAuthorizedAccounts(false) + .build() + ) + .build() + + val result = oneTapClient.beginSignIn(signInRequest).await() + val intentSenderRequest = IntentSenderRequest.Builder(result.pendingIntent).build() + launcher.launch(intentSenderRequest) + + } catch (e: Exception) { + _authState.value = AuthState.Error("Sign in failed: ${e.message}") + } + } + } + + fun handleSignInResult(intent: Intent?) { + viewModelScope.launch { + try { + val credential = oneTapClient.getSignInCredentialFromIntent(intent) + val idToken = credential.googleIdToken + + if (idToken != null) { + val firebaseCredential = GoogleAuthProvider.getCredential(idToken, null) + val authResult = auth.signInWithCredential(firebaseCredential).await() + val user = authResult.user + + if (user != null) { + _authState.value = AuthState.Authenticated(user.email ?: "User") + } else { + _authState.value = AuthState.Error("Authentication failed") + } + } else { + _authState.value = AuthState.Error("No ID token received") + } + } catch (e: Exception) { + _authState.value = AuthState.Error("Sign in error: ${e.message}") + } + } + } + + fun signOut(context: Context) { + viewModelScope.launch { + auth.signOut() + oneTapClient.signOut().await() + _authState.value = AuthState.NotAuthenticated + } + } +} + +sealed class AuthState { + object Loading : AuthState() + object ShowOnboarding : AuthState() + object NotAuthenticated : AuthState() + data class Authenticated(val email: String) : AuthState() + data class Error(val message: String) : AuthState() +} \ No newline at end of file diff --git a/app/src/main/res/drawable/inventorymanger_background.xml b/app/src/main/res/drawable/inventorymanger_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/inventorymanger_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/inventorymanger.xml b/app/src/main/res/mipmap-anydpi-v26/inventorymanger.xml new file mode 100644 index 0000000..874170e --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/inventorymanger.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/inventorymanger_round.xml b/app/src/main/res/mipmap-anydpi-v26/inventorymanger_round.xml new file mode 100644 index 0000000..874170e --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/inventorymanger_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/inventorymanger.webp b/app/src/main/res/mipmap-hdpi/inventorymanger.webp new file mode 100644 index 0000000..72259b5 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/inventorymanger.webp differ diff --git a/app/src/main/res/mipmap-hdpi/inventorymanger_foreground.webp b/app/src/main/res/mipmap-hdpi/inventorymanger_foreground.webp new file mode 100644 index 0000000..c50c46b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/inventorymanger_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/inventorymanger_round.webp b/app/src/main/res/mipmap-hdpi/inventorymanger_round.webp new file mode 100644 index 0000000..505a778 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/inventorymanger_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/inventorymanger.webp b/app/src/main/res/mipmap-mdpi/inventorymanger.webp new file mode 100644 index 0000000..a5bac1c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/inventorymanger.webp differ diff --git a/app/src/main/res/mipmap-mdpi/inventorymanger_foreground.webp b/app/src/main/res/mipmap-mdpi/inventorymanger_foreground.webp new file mode 100644 index 0000000..9bec189 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/inventorymanger_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/inventorymanger_round.webp b/app/src/main/res/mipmap-mdpi/inventorymanger_round.webp new file mode 100644 index 0000000..07eda00 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/inventorymanger_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/inventorymanger.webp b/app/src/main/res/mipmap-xhdpi/inventorymanger.webp new file mode 100644 index 0000000..cbbfbd8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/inventorymanger.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/inventorymanger_foreground.webp b/app/src/main/res/mipmap-xhdpi/inventorymanger_foreground.webp new file mode 100644 index 0000000..eaa08db Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/inventorymanger_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/inventorymanger_round.webp b/app/src/main/res/mipmap-xhdpi/inventorymanger_round.webp new file mode 100644 index 0000000..9f2b785 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/inventorymanger_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/inventorymanger.webp b/app/src/main/res/mipmap-xxhdpi/inventorymanger.webp new file mode 100644 index 0000000..d363c3a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/inventorymanger.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/inventorymanger_foreground.webp b/app/src/main/res/mipmap-xxhdpi/inventorymanger_foreground.webp new file mode 100644 index 0000000..ff05c79 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/inventorymanger_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/inventorymanger_round.webp b/app/src/main/res/mipmap-xxhdpi/inventorymanger_round.webp new file mode 100644 index 0000000..aa6a30e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/inventorymanger_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/inventorymanger.webp b/app/src/main/res/mipmap-xxxhdpi/inventorymanger.webp new file mode 100644 index 0000000..56a3b25 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/inventorymanger.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/inventorymanger_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/inventorymanger_foreground.webp new file mode 100644 index 0000000..356ec5b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/inventorymanger_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/inventorymanger_round.webp b/app/src/main/res/mipmap-xxxhdpi/inventorymanger_round.webp new file mode 100644 index 0000000..406ac75 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/inventorymanger_round.webp differ diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..4600e6c --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index 9ac162a..f5eb1ad 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -1,4 +1,22 @@ - - + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index f7b5371..f0e74b0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,9 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } +} plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c3d1c4..0ec29da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] agp = "8.13.0" -kotlin = "2.0.21" +kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" -composeBom = "2024.09.00" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.1" +composeBom = "2025.12.00" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -28,5 +28,8 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +#noinspection SimilarGradleDependency kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +#noinspection SimilarGradleDependency +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version = "2.2.21" } diff --git a/settings.gradle.kts b/settings.gradle.kts index fc48852..fd65ced 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,24 +1,21 @@ +// In your C:\Garage\AndroidStudioCode\settings.gradle.kts file + pluginManagement { repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } + google() mavenCentral() gradlePluginPortal() } } + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() - mavenCentral() + mavenCentral() // THIS IS CRITICAL + maven("https://jitpack.io") } } -rootProject.name = "InventoryManager" -include(":app") - \ No newline at end of file +rootProject.name = "Inventory Manager" +include(":app") \ No newline at end of file