From c83a99ef0a5c6b37ee1f65fc9338b7d53a2dde12 Mon Sep 17 00:00:00 2001 From: Ionut253 <48173899+Ionut253@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:19:31 +0200 Subject: [PATCH 1/2] Receipt screen wired to backend --- .../android/data/model/ProcessedItem.kt | 9 + .../data/model/ReceiptProcessResponse.kt | 6 + .../android/data/network/ReceiptApiService.kt | 14 + .../android/data/network/RetrofitClient.kt | 14 + .../android/ui/screens/ReceiptScreen.kt | 654 +++++++++--------- .../android/ui/viewmodels/ReceiptViewModel.kt | 149 ++++ .../ui/viewmodels/ReceiptViewModelFactory.kt | 14 + 7 files changed, 543 insertions(+), 317 deletions(-) create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ProcessedItem.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ReceiptProcessResponse.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ReceiptApiService.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ProcessedItem.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ProcessedItem.kt new file mode 100644 index 0000000..c933746 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ProcessedItem.kt @@ -0,0 +1,9 @@ +package com.example.budgeting.android.data.model + +data class ProcessedItem( + val name: String, + val quantity: Int, + val price: Double, + val category: String, + val keywords: List +) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ReceiptProcessResponse.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ReceiptProcessResponse.kt new file mode 100644 index 0000000..62d6e63 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ReceiptProcessResponse.kt @@ -0,0 +1,6 @@ +package com.example.budgeting.android.data.model + +data class ReceiptProcessResponse( + val items: List, + val total: Double +) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ReceiptApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ReceiptApiService.kt new file mode 100644 index 0000000..936061d --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ReceiptApiService.kt @@ -0,0 +1,14 @@ +package com.example.budgeting.android.data.network + +import com.example.budgeting.android.data.model.ReceiptProcessResponse +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part + +interface ReceiptApiService { + @Multipart + @POST("/receipt/process-receipt") + suspend fun processReceipt(@Part image: MultipartBody.Part): Response +} \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt index 786b3a9..b3c99e9 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt @@ -107,4 +107,18 @@ object RetrofitClient { retrofit.create(CategoryApiService::class.java) } + + val receiptInstance: ReceiptApiService = run { + val client = OkHttpClient.Builder() + .addInterceptor(TokenAuthInterceptor()) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(moshiConverterFactory) + .build() + + retrofit.create(ReceiptApiService::class.java) + } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt index e107c39..4d4ac7c 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt @@ -1,31 +1,55 @@ package com.example.budgeting.android.ui.screens +import ReceiptViewModelFactory +import android.Manifest +import android.content.Context +import android.graphics.Bitmap +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Camera -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.automirrored.filled.ReceiptLong +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ShoppingBag +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.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import android.Manifest -import android.graphics.Bitmap +import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker -import coil.compose.AsyncImage -import com.example.budgeting.android.data.model.Receipt +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.budgeting.android.data.model.ProcessedItem +import com.example.budgeting.android.ui.viewmodels.ReceiptUiState +import com.example.budgeting.android.ui.viewmodels.ReceiptViewModel +import kotlinx.coroutines.launch import java.io.File import java.io.FileOutputStream import java.util.UUID @@ -34,340 +58,324 @@ import java.util.UUID @Composable fun ReceiptScreen() { val context = LocalContext.current - val receipts = remember { - mutableStateListOf() - } + val vm: ReceiptViewModel = viewModel(factory = ReceiptViewModelFactory(context)) + val uiState by vm.uiState.collectAsState() + val scannedItems by vm.scannedItems.collectAsState() + val scope = rememberCoroutineScope() - // Camera result forwarder that the dialog can set - val cameraResultHandler = remember { mutableStateOf<(String?) -> Unit>({}) } - - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = { /* permission flow handled by launchCameraWith */ } - ) - - // Camera launcher (returns a Bitmap preview) and forwards to current handler val cameraLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.TakePicturePreview(), onResult = { bitmap: Bitmap? -> - val uriString = bitmap?.let { saveBitmapToCache(context.cacheDir, it) } - cameraResultHandler.value.invoke(uriString) + bitmap?.let { + val uri = saveBitmapToCacheUri(context, it) + vm.processReceiptImage(uri, context) + } } ) - fun launchCameraWith(onPicture: (String?) -> Unit) { - cameraResultHandler.value = onPicture - val hasPermission = ContextCompat.checkSelfPermission( - context, - Manifest.permission.CAMERA - ) == PermissionChecker.PERMISSION_GRANTED + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> + if (isGranted) { + cameraLauncher.launch(null) + } + } + ) - if (hasPermission) { + fun launchCamera() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PermissionChecker.PERMISSION_GRANTED) { cameraLauncher.launch(null) } else { permissionLauncher.launch(Manifest.permission.CAMERA) } } - var showAddDialog by remember { mutableStateOf(false) } - Scaffold( topBar = { TopAppBar( title = { Text( - text = "Groups", + text = "Receipts", style = MaterialTheme.typography.titleLarge ) - } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface + ) ) - }, - bottomBar = { - Surface(color = MaterialTheme.colorScheme.background) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Button( - onClick = { showAddDialog = true }, - modifier = Modifier - .weight(1f) - .height(52.dp), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { - Text("Add receipt") - } - } - } } ) { padding -> - Box( + Column( modifier = Modifier .padding(padding) .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - if (receipts.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("🧾", style = MaterialTheme.typography.displayLarge) - Text( - text = "No receipts yet", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "Tap 'Add receipt' to get started", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + when (uiState) { + is ReceiptUiState.Idle -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ReceiptLong, + contentDescription = null, + tint = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(96.dp) + .padding(bottom = 16.dp) + ) + Text( + text = "No receipt scanned", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Tap \"Scan receipt\" to capture a receipt and automatically create expenses.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + is ReceiptUiState.Loading -> { + Card( + modifier = Modifier + .fillMaxWidth(0.8f) + .wrapContentHeight(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + androidx.compose.material3.CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + val msg = (uiState as ReceiptUiState.Loading).message + Text( + text = msg, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + } + } + + is ReceiptUiState.Reviewing -> { + if (scannedItems.isNotEmpty()) { + ReceiptReviewDialog( + items = scannedItems, + onConfirm = { + scope.launch { vm.saveExpenses() } + }, + onCancel = { vm.cancelReview() } + ) + } + } + + is ReceiptUiState.Success -> { + val message = (uiState as ReceiptUiState.Success).message + AlertDialog( + onDismissRequest = { vm.dismissError() }, + confirmButton = { + TextButton(onClick = { vm.dismissError() }) { + Text("OK") + } + }, + title = { + Text( + text = "Done", + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium + ) + } ) } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Top - ) { - items(receipts) { receipt -> - ReceiptRow( - title = receipt.merchant, - subtitle = receipt.category, - amount = receipt.amount, - thumbnailUrl = receipt.thumbnailUrl + + is ReceiptUiState.Error -> { + val message = (uiState as ReceiptUiState.Error).message + AlertDialog( + onDismissRequest = { vm.dismissError() }, + confirmButton = { + TextButton(onClick = { vm.dismissError() }) { + Text("OK") + } + }, + title = { + Text( + text = "Something went wrong", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.error + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium + ) + } ) } } } - } - if (showAddDialog) { - AddReceiptDialog( - onDismiss = { showAddDialog = false }, - onScan = { onPicture -> launchCameraWith(onPicture) }, - onAdd = { merchant, category, amount, thumbnailUrl -> - receipts.add( - 0, - Receipt( - id = UUID.randomUUID().toString(), - merchant = merchant.ifBlank { "Untitled" }, - category = category.ifBlank { "Uncategorized" }, - amount = amount, - thumbnailUrl = thumbnailUrl - ) + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .fillMaxWidth() + ) { + Button( + onClick = { launchCamera() }, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = "Scan receipt" ) - showAddDialog = false + Spacer(modifier = Modifier.size(8.dp)) + Text("Scan receipt") } - ) + } } } } -private fun saveBitmapToCache(cacheDir: File, bitmap: Bitmap): String { - val file = File(cacheDir, "receipt-${UUID.randomUUID()}.jpg") - FileOutputStream(file).use { out -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 92, out) - } - return file.toURI().toString() -} - @Composable -private fun AddReceiptDialog( - onDismiss: () -> Unit, - onScan: (onPicture: (String?) -> Unit) -> Unit, - onAdd: (merchant: String, category: String, amount: Double, thumbnailUrl: String?) -> Unit +fun ReceiptReviewDialog( + items: List, + onConfirm: () -> Unit, + onCancel: () -> Unit ) { - Dialog(onDismissRequest = onDismiss) { - Surface( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(16.dp), + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.95f) + .heightIn(max = 600.dp) + .padding(16.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(8.dp) ) { Column( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() + modifier = Modifier.padding(20.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { - Text("✕", color = MaterialTheme.colorScheme.onBackground) - } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text( - text = "New Receipt", - color = MaterialTheme.colorScheme.onBackground - ) - } - Spacer(modifier = Modifier.size(32.dp)) - } + Text( + text = "Review items", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + Text( + text = "${items.size} items detected. Check categories before saving.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - // Inputs - val merchant = remember { mutableStateOf("") } - val category = remember { mutableStateOf("") } - val amountText = remember { mutableStateOf("") } - val thumbnail = remember { mutableStateOf(null) } + Spacer(Modifier.height(16.dp)) - Column( - modifier = Modifier - .padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - TextField( - value = merchant.value, - onValueChange = { merchant.value = it }, - placeholder = { Text("Merchant") }, - label = { Text("Merchant") }, - singleLine = true, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.fillMaxWidth() - ) + items(items) { item -> + ScannedItemRow(item) + } + } - TextField( - value = category.value, - onValueChange = { category.value = it }, - placeholder = { Text("Category") }, - label = { Text("Category") }, - singleLine = true, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.fillMaxWidth() - ) + Spacer(Modifier.height(16.dp)) - TextField( - value = amountText.value, - onValueChange = { input -> - // Allow only digits and at most one dot - val filtered = buildString { - var dotSeen = false - input.forEach { ch -> - if (ch.isDigit()) append(ch) - else if (ch == '.' && !dotSeen) { - append(ch) - dotSeen = true - } - } - } - amountText.value = filtered - }, - placeholder = { Text("Amount") }, - label = { Text("Amount") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.fillMaxWidth() + val total = items.sumOf { it.price } + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer ) - - // Thumbnail preview + Scan button + ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Surface( - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant - ) { - Box( - modifier = Modifier.size(64.dp), - contentAlignment = Alignment.Center - ) { - if (thumbnail.value != null) { - AsyncImage( - model = thumbnail.value, - contentDescription = null, - modifier = Modifier.clip(RoundedCornerShape(10.dp)), - contentScale = ContentScale.Crop - ) - } else { - Text("🧾") - } - } - } - - Button( - onClick = { - onScan { uri -> thumbnail.value = uri } - }, - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) { - Icon( - imageVector = Icons.Filled.Camera, - contentDescription = "Scan" - ) - Spacer(Modifier.size(8.dp)) - Text("Scan") - } + Text( + text = "Total amount", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "$${String.format("%.2f", total)}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) } } - Button( - onClick = { - val amount = amountText.value.toDoubleOrNull() ?: 0.0 - onAdd( - merchant.value.trim(), - category.value.trim(), - amount, - thumbnail.value - ) - }, - enabled = merchant.value.isNotBlank(), - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - .height(50.dp), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End ) { - Text("Add receipt") + TextButton( + onClick = onCancel, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text("Cancel") + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Save expenses") + } } } } @@ -375,53 +383,65 @@ private fun AddReceiptDialog( } @Composable -private fun ReceiptRow( - title: String, - subtitle: String, - amount: Double, - thumbnailUrl: String? -) { +fun ScannedItemRow(item: ProcessedItem) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) + .padding(vertical = 8.dp, horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.size(48.dp) ) { - Box( - modifier = Modifier.size(48.dp), - contentAlignment = Alignment.Center - ) { - if (thumbnailUrl != null) { - AsyncImage( - model = thumbnailUrl, - contentDescription = null, - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop - ) - } else { - Text("🧾") - } + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.ShoppingBag, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) } } - Spacer(modifier = Modifier.size(12.dp)) + Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { - Text(title, color = MaterialTheme.colorScheme.onSurface) Text( - subtitle, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall + text = item.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = item.category, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium ) } Text( - text = "$${"%.2f".format(amount)}", + text = "$${String.format("%.2f", item.price)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) } + + HorizontalDivider( + Modifier, + DividerDefaults.Thickness, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) } + +private fun saveBitmapToCacheUri(context: Context, bitmap: Bitmap): android.net.Uri { + val file = File(context.cacheDir, "scan_${UUID.randomUUID()}.jpg") + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + return android.net.Uri.fromFile(file) +} \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt new file mode 100644 index 0000000..7531543 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt @@ -0,0 +1,149 @@ +package com.example.budgeting.android.ui.viewmodels + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.budgeting.android.data.auth.TokenHolder +import com.example.budgeting.android.data.local.TokenDataStore +import com.example.budgeting.android.data.model.* +import com.example.budgeting.android.data.network.RetrofitClient +import com.example.budgeting.android.data.repository.CategoryRepository +import com.example.budgeting.android.data.repository.ExpenseRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.io.FileOutputStream + +sealed class ReceiptUiState { + object Idle : ReceiptUiState() + data class Loading(val message: String) : ReceiptUiState() + object Reviewing : ReceiptUiState() + data class Success(val message: String) : ReceiptUiState() + data class Error(val message: String) : ReceiptUiState() +} + +class ReceiptViewModel(context: Context) : ViewModel() { + private val tokenDataStore = TokenDataStore(context.applicationContext) + + private val expenseRepository = ExpenseRepository(RetrofitClient.expenseInstance, tokenDataStore) + private val categoryRepository = CategoryRepository(RetrofitClient.categoryInstance) + private val receiptApi = RetrofitClient.receiptInstance + + private val _uiState = MutableStateFlow(ReceiptUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _scannedItems = MutableStateFlow>(emptyList()) + val scannedItems: StateFlow> = _scannedItems.asStateFlow() + + init { + viewModelScope.launch { + val savedToken = tokenDataStore.tokenFlow.firstOrNull() + if (!savedToken.isNullOrBlank()) TokenHolder.token = savedToken + } + } + + fun processReceiptImage(uri: Uri, context: Context) { + viewModelScope.launch { + _uiState.value = ReceiptUiState.Loading("Analyzing receipt with AI...") + + try { + val file = File(context.cacheDir, "receipt_upload.jpg") + withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(file).use { output -> input.copyTo(output) } + } + } + + val reqBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("image", file.name, reqBody) + + val response = receiptApi.processReceipt(part) + + if (response.isSuccessful && response.body() != null) { + _scannedItems.value = response.body()!!.items + _uiState.value = ReceiptUiState.Reviewing + } else { + _uiState.value = ReceiptUiState.Error("AI Analysis failed: ${response.code()}") + } + } catch (e: Exception) { + _uiState.value = ReceiptUiState.Error("Error: ${e.message}") + } + } + } + + fun saveExpenses() { + viewModelScope.launch { + _uiState.value = ReceiptUiState.Loading("Saving expenses...") + + try { + var existingCategories = try { + categoryRepository.getCategories(null, null) + } catch (e: Exception) { emptyList() } + + val itemsToSave = _scannedItems.value + var successCount = 0 + + itemsToSave.forEach { item -> + var targetCategory = existingCategories.find { + it.title.equals(item.category, ignoreCase = true) + } + + if (targetCategory == null) { + try { + categoryRepository.addCategory( + CategoryBody(title = item.category, keywords = item.keywords) + ) + existingCategories = categoryRepository.getCategories(null, null) + targetCategory = existingCategories.find { + it.title.equals(item.category, ignoreCase = true) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + if (targetCategory != null) { + try { + expenseRepository.addExpense( + Expense( + id = null, + title = item.name, + amount = item.price, + categoryId = targetCategory.id!!, + description = "Scanned Receipt Item", + user_id = null, + group_id = null, + created_at = null + ) + ) + successCount++ + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + _uiState.value = ReceiptUiState.Success("Successfully saved $successCount expenses!") + _scannedItems.value = emptyList() + + } catch (e: Exception) { + _uiState.value = ReceiptUiState.Error("Failed to save: ${e.message}") + } + } + } + + fun dismissError() { _uiState.value = ReceiptUiState.Idle } + fun cancelReview() { + _scannedItems.value = emptyList() + _uiState.value = ReceiptUiState.Idle + } +} diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt new file mode 100644 index 0000000..57de221 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt @@ -0,0 +1,14 @@ +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.budgeting.android.ui.viewmodels.ReceiptViewModel + +class ReceiptViewModelFactory(private val context: Context) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ReceiptViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return ReceiptViewModel(context) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file From 9edb4277dec4f921b58d27d0110947e3eb799a16 Mon Sep 17 00:00:00 2001 From: Ionut253 <48173899+Ionut253@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:43:46 +0200 Subject: [PATCH 2/2] Refactor receipt scanning flow and UI improvements --- API/routes/receipt_routes.py | 2 +- Mobile/androidApp/build.gradle.kts | 1 + .../android/data/network/RetrofitClient.kt | 6 +- .../android/ui/screens/ReceiptScreen.kt | 506 +++++++++--------- .../android/ui/viewmodels/ReceiptViewModel.kt | 123 +++-- .../ui/viewmodels/ReceiptViewModelFactory.kt | 6 +- Mobile/gradle/libs.versions.toml | 2 + 7 files changed, 341 insertions(+), 305 deletions(-) diff --git a/API/routes/receipt_routes.py b/API/routes/receipt_routes.py index 5a90df0..7f59083 100644 --- a/API/routes/receipt_routes.py +++ b/API/routes/receipt_routes.py @@ -3,7 +3,7 @@ from services.receipt_service import IReceiptService from utils.helpers.jwt_utils import JwtUtils -router = APIRouter(prefix="/receipt", tags=["Receipt"]) +router = APIRouter(tags=["Receipt"]) def get_current_user_id(request: Request) -> int: """ diff --git a/Mobile/androidApp/build.gradle.kts b/Mobile/androidApp/build.gradle.kts index ed5161d..2ed9cba 100644 --- a/Mobile/androidApp/build.gradle.kts +++ b/Mobile/androidApp/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { implementation(libs.compose.material3) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.foundation) debugImplementation(libs.compose.ui.tooling) // Core Compose dependencies diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt index b3c99e9..bf41087 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt @@ -7,6 +7,7 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.concurrent.TimeUnit object RetrofitClient { private val BASE_URL: String = BuildConfig.BASE_URL @@ -18,7 +19,7 @@ object RetrofitClient { private val moshi = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() - + fun getMoshi(): Moshi = moshi private val moshiConverterFactory = MoshiConverterFactory.create(moshi) @@ -111,6 +112,9 @@ object RetrofitClient { val receiptInstance: ReceiptApiService = run { val client = OkHttpClient.Builder() .addInterceptor(TokenAuthInterceptor()) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) .build() val retrofit = Retrofit.Builder() diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt index 4d4ac7c..9cb19b1 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt @@ -2,11 +2,14 @@ package com.example.budgeting.android.ui.screens import ReceiptViewModelFactory import android.Manifest +import android.app.Application import android.content.Context import android.graphics.Bitmap +import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -16,33 +19,18 @@ import androidx.compose.material.icons.automirrored.filled.ReceiptLong import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ShoppingBag -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.DividerDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.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.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.zIndex import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker import androidx.lifecycle.viewmodel.compose.viewModel @@ -58,17 +46,21 @@ import java.util.UUID @Composable fun ReceiptScreen() { val context = LocalContext.current - val vm: ReceiptViewModel = viewModel(factory = ReceiptViewModelFactory(context)) + val scope = rememberCoroutineScope() + + val vm: ReceiptViewModel = viewModel( + factory = ReceiptViewModelFactory(context.applicationContext as Application) + ) + val uiState by vm.uiState.collectAsState() val scannedItems by vm.scannedItems.collectAsState() - val scope = rememberCoroutineScope() val cameraLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.TakePicturePreview(), onResult = { bitmap: Bitmap? -> bitmap?.let { val uri = saveBitmapToCacheUri(context, it) - vm.processReceiptImage(uri, context) + vm.processReceiptImage(uri) } } ) @@ -76,9 +68,7 @@ fun ReceiptScreen() { val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { isGranted -> - if (isGranted) { - cameraLauncher.launch(null) - } + if (isGranted) cameraLauncher.launch(null) } ) @@ -93,12 +83,7 @@ fun ReceiptScreen() { Scaffold( topBar = { TopAppBar( - title = { - Text( - text = "Receipts", - style = MaterialTheme.typography.titleLarge - ) - }, + title = { Text("Receipts", style = MaterialTheme.typography.titleLarge) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface, titleContentColor = MaterialTheme.colorScheme.onSurface @@ -106,178 +91,109 @@ fun ReceiptScreen() { ) } ) { padding -> - Column( + Box( modifier = Modifier .padding(padding) .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentAlignment = Alignment.Center + + Column( + modifier = Modifier.fillMaxSize() ) { - when (uiState) { - is ReceiptUiState.Idle -> { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ReceiptLong, - contentDescription = null, - tint = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier - .size(96.dp) - .padding(bottom = 16.dp) - ) - Text( - text = "No receipt scanned", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(8.dp)) - Text( - text = "Tap \"Scan receipt\" to capture a receipt and automatically create expenses.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + when (uiState) { + is ReceiptUiState.Idle -> EmptyStateContent() + is ReceiptUiState.Loading -> { } - is ReceiptUiState.Loading -> { - Card( - modifier = Modifier - .fillMaxWidth(0.8f) - .wrapContentHeight(), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) - ) { - Column( - modifier = Modifier - .padding(20.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - androidx.compose.material3.CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) - val msg = (uiState as ReceiptUiState.Loading).message - Text( - text = msg, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center + is ReceiptUiState.Reviewing -> { + if (scannedItems.isNotEmpty()) { + ReceiptReviewDialog( + items = scannedItems, + onConfirm = { selectedItems -> + scope.launch { vm.saveExpenses(selectedItems) } + }, + onCancel = { vm.cancelReview() } ) } } - } - - is ReceiptUiState.Reviewing -> { - if (scannedItems.isNotEmpty()) { - ReceiptReviewDialog( - items = scannedItems, - onConfirm = { - scope.launch { vm.saveExpenses() } - }, - onCancel = { vm.cancelReview() } + is ReceiptUiState.Success -> { + ResultAlertDialog( + title = "Done", + message = (uiState as ReceiptUiState.Success).message, + onDismiss = { vm.dismissError() } + ) + } + is ReceiptUiState.Error -> { + ResultAlertDialog( + title = "Error", + message = (uiState as ReceiptUiState.Error).message, + isError = true, + onDismiss = { vm.dismissError() } ) } } + } - is ReceiptUiState.Success -> { - val message = (uiState as ReceiptUiState.Success).message - AlertDialog( - onDismissRequest = { vm.dismissError() }, - confirmButton = { - TextButton(onClick = { vm.dismissError() }) { - Text("OK") - } - }, - title = { - Text( - text = "Done", - style = MaterialTheme.typography.titleLarge - ) - }, - text = { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium - ) - } - ) - } - - is ReceiptUiState.Error -> { - val message = (uiState as ReceiptUiState.Error).message - AlertDialog( - onDismissRequest = { vm.dismissError() }, - confirmButton = { - TextButton(onClick = { vm.dismissError() }) { - Text("OK") - } - }, - title = { - Text( - text = "Something went wrong", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.error - ) - }, - text = { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium - ) - } + PaddingValues(16.dp).let { + Button( + onClick = { launchCamera() }, + enabled = uiState is ReceiptUiState.Idle || uiState is ReceiptUiState.Error || uiState is ReceiptUiState.Success, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary ) + ) { + Icon(Icons.Default.CameraAlt, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Scan Receipt", style = MaterialTheme.typography.titleMedium) } } } - Row( - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) - .fillMaxWidth() - ) { - Button( - onClick = { launchCamera() }, + if (uiState is ReceiptUiState.Loading) { + val message = (uiState as ReceiptUiState.Loading).message + Box( modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)) + .zIndex(2f), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Default.CameraAlt, - contentDescription = "Scan receipt" - ) - Spacer(modifier = Modifier.size(8.dp)) - Text("Scan receipt") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 6.dp + ) + Spacer(Modifier.height(24.dp)) + Text( + text = message, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + } } } } } } - @Composable fun ReceiptReviewDialog( items: List, - onConfirm: () -> Unit, + onConfirm: (List) -> Unit, onCancel: () -> Unit ) { + val selectedItems = remember { mutableStateListOf().apply { addAll(items) } } + Dialog( onDismissRequest = onCancel, properties = DialogProperties(usePlatformDefaultWidth = false) @@ -285,23 +201,19 @@ fun ReceiptReviewDialog( Card( modifier = Modifier .fillMaxWidth(0.95f) - .heightIn(max = 600.dp) + .fillMaxHeight(0.85f) .padding(16.dp), - shape = RoundedCornerShape(24.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - elevation = CardDefaults.cardElevation(8.dp) + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) ) { - Column( - modifier = Modifier.padding(20.dp) - ) { + Column(modifier = Modifier.padding(24.dp)) { Text( - text = "Review items", + text = "Review Items", style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold ) Text( - text = "${items.size} items detected. Check categories before saving.", + text = "${selectedItems.size} of ${items.size} items selected", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -310,71 +222,52 @@ fun ReceiptReviewDialog( LazyColumn( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(items) { item -> - ScannedItemRow(item) + val isSelected = selectedItems.contains(item) + SelectableItemRow( + item = item, + isSelected = isSelected, + onToggle = { + if (isSelected) selectedItems.remove(item) + else selectedItems.add(item) + } + ) } } + Spacer(Modifier.height(16.dp)) + HorizontalDivider() Spacer(Modifier.height(16.dp)) - val total = items.sumOf { it.price } - Card( + val total = selectedItems.sumOf { it.price } + Row( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Total amount", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = "$${String.format("%.2f", total)}", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } + Text("Total Selected", style = MaterialTheme.typography.titleMedium) + Text( + "$${String.format("%.2f", total)}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) } Spacer(Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton( - onClick = onCancel, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) { - Text("Cancel") - } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onCancel) { Text("Cancel") } Spacer(Modifier.width(8.dp)) Button( - onClick = onConfirm, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) + onClick = { onConfirm(selectedItems.toList()) }, + enabled = selectedItems.isNotEmpty() ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Save expenses") + Text("Save (${selectedItems.size})") } } } @@ -383,26 +276,41 @@ fun ReceiptReviewDialog( } @Composable -fun ScannedItemRow(item: ProcessedItem) { +fun SelectableItemRow( + item: ProcessedItem, + isSelected: Boolean, + onToggle: () -> Unit +) { Row( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) - .padding(vertical = 8.dp, horizontal = 12.dp), + .clickable { onToggle() } + .background( + if (isSelected) MaterialTheme.colorScheme.surfaceContainerLow else Color.Transparent, + RoundedCornerShape(12.dp) + ) + .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { - Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - modifier = Modifier.size(48.dp) + // Checkbox for selection + Checkbox( + checked = isSelected, + onCheckedChange = { onToggle() } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Surface( + shape = RoundedCornerShape(8.dp), + color = if(isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.size(40.dp) ) { Box(contentAlignment = Alignment.Center) { Icon( - imageVector = Icons.Default.ShoppingBag, + Icons.Default.ShoppingBag, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = if(isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) ) } } @@ -413,35 +321,127 @@ fun ScannedItemRow(item: ProcessedItem) { Text( text = item.name, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + fontWeight = if(isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha=0.6f) ) Text( text = item.category, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium + color = MaterialTheme.colorScheme.primary ) } Text( text = "$${String.format("%.2f", item.price)}", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha=0.6f) + ) + } +} + +@Composable +fun EmptyStateContent() { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ReceiptLong, + contentDescription = null, + tint = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.size(120.dp) + ) + Spacer(Modifier.height(24.dp)) + Text( + text = "No receipt scanned", + style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Snap a photo of your receipt to automatically extract and save expenses.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) } +} - HorizontalDivider( - Modifier, - DividerDefaults.Thickness, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) +@Composable +fun ResultAlertDialog( + title: String, + message: String, + isError: Boolean = false, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { Text("OK") } + }, + title = { + Text( + text = title, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + ) + }, + text = { Text(message) } ) } -private fun saveBitmapToCacheUri(context: Context, bitmap: Bitmap): android.net.Uri { +@Composable +fun ScannedItemRow(item: ProcessedItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLow, RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(40.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.ShoppingBag, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp) + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = item.category, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + + Text( + text = "$${String.format("%.2f", item.price)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } +} + +private fun saveBitmapToCacheUri(context: Context, bitmap: Bitmap): Uri { val file = File(context.cacheDir, "scan_${UUID.randomUUID()}.jpg") FileOutputStream(file).use { out -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out) } - return android.net.Uri.fromFile(file) -} \ No newline at end of file + return Uri.fromFile(file) +} diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt index 7531543..660b49f 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt @@ -1,8 +1,8 @@ package com.example.budgeting.android.ui.viewmodels -import android.content.Context +import android.app.Application import android.net.Uri -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.example.budgeting.android.data.auth.TokenHolder import com.example.budgeting.android.data.local.TokenDataStore @@ -22,6 +22,7 @@ import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import java.io.File import java.io.FileOutputStream +import java.util.UUID sealed class ReceiptUiState { object Idle : ReceiptUiState() @@ -31,9 +32,9 @@ sealed class ReceiptUiState { data class Error(val message: String) : ReceiptUiState() } -class ReceiptViewModel(context: Context) : ViewModel() { - private val tokenDataStore = TokenDataStore(context.applicationContext) +class ReceiptViewModel(application: Application) : AndroidViewModel(application) { + private val tokenDataStore = TokenDataStore(application) private val expenseRepository = ExpenseRepository(RetrofitClient.expenseInstance, tokenDataStore) private val categoryRepository = CategoryRepository(RetrofitClient.categoryInstance) private val receiptApi = RetrofitClient.receiptInstance @@ -51,19 +52,22 @@ class ReceiptViewModel(context: Context) : ViewModel() { } } - fun processReceiptImage(uri: Uri, context: Context) { + fun processReceiptImage(uri: Uri) { viewModelScope.launch { _uiState.value = ReceiptUiState.Loading("Analyzing receipt with AI...") try { - val file = File(context.cacheDir, "receipt_upload.jpg") - withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(file).use { output -> input.copyTo(output) } - } + val file = withContext(Dispatchers.IO) { + createTempFileFromUri(uri) } - val reqBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) + if (file == null || file.length() == 0L) { + _uiState.value = ReceiptUiState.Error("Error: Image file is empty or invalid") + return@launch + } + + val mediaType = "image/jpeg".toMediaTypeOrNull() + val reqBody = file.asRequestBody(mediaType) val part = MultipartBody.Part.createFormData("image", file.name, reqBody) val response = receiptApi.processReceipt(part) @@ -74,60 +78,68 @@ class ReceiptViewModel(context: Context) : ViewModel() { } else { _uiState.value = ReceiptUiState.Error("AI Analysis failed: ${response.code()}") } + + file.delete() + } catch (e: Exception) { _uiState.value = ReceiptUiState.Error("Error: ${e.message}") } } } - fun saveExpenses() { + fun saveExpenses(itemsToSave: List) { viewModelScope.launch { _uiState.value = ReceiptUiState.Loading("Saving expenses...") try { - var existingCategories = try { + val initialCategories = try { categoryRepository.getCategories(null, null) } catch (e: Exception) { emptyList() } - - val itemsToSave = _scannedItems.value + + val localCategoryCache = initialCategories.toMutableList() var successCount = 0 - itemsToSave.forEach { item -> - var targetCategory = existingCategories.find { - it.title.equals(item.category, ignoreCase = true) - } + withContext(Dispatchers.IO) { + itemsToSave.forEach { item -> + + var targetCategory = localCategoryCache.find { + it.title.equals(item.category, ignoreCase = true) + } - if (targetCategory == null) { - try { - categoryRepository.addCategory( - CategoryBody(title = item.category, keywords = item.keywords) - ) - existingCategories = categoryRepository.getCategories(null, null) - targetCategory = existingCategories.find { - it.title.equals(item.category, ignoreCase = true) + if (targetCategory == null) { + try { + val newCategory = categoryRepository.addCategory( + CategoryBody(title = item.category, keywords = item.keywords) + ) + val refreshedCats = categoryRepository.getCategories(null, null) + targetCategory = refreshedCats.find { it.title.equals(item.category, ignoreCase = true) } + + if (targetCategory != null) { + localCategoryCache.add(targetCategory) + } + } catch (e: Exception) { + e.printStackTrace() } - } catch (e: Exception) { - e.printStackTrace() } - } - if (targetCategory != null) { - try { - expenseRepository.addExpense( - Expense( - id = null, - title = item.name, - amount = item.price, - categoryId = targetCategory.id!!, - description = "Scanned Receipt Item", - user_id = null, - group_id = null, - created_at = null + if (targetCategory?.id != null) { + try { + expenseRepository.addExpense( + Expense( + id = null, + title = item.name, + amount = item.price, + categoryId = targetCategory.id!!, + description = "Scanned Receipt Item", + user_id = null, + group_id = null, + created_at = null + ) ) - ) - successCount++ - } catch (e: Exception) { - e.printStackTrace() + successCount++ + } catch (e: Exception) { + e.printStackTrace() + } } } } @@ -141,9 +153,26 @@ class ReceiptViewModel(context: Context) : ViewModel() { } } + private fun createTempFileFromUri(uri: Uri): File? { + return try { + val context = getApplication() + val stream = context.contentResolver.openInputStream(uri) ?: return null + val file = File(context.cacheDir, "receipt_${UUID.randomUUID()}.jpg") + FileOutputStream(file).use { output -> + stream.copyTo(output) + } + stream.close() + file + } catch (e: Exception) { + e.printStackTrace() + null + } + } + fun dismissError() { _uiState.value = ReceiptUiState.Idle } + fun cancelReview() { _scannedItems.value = emptyList() _uiState.value = ReceiptUiState.Idle } -} +} \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt index 57de221..32eca6a 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt @@ -1,13 +1,13 @@ -import android.content.Context +import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.example.budgeting.android.ui.viewmodels.ReceiptViewModel -class ReceiptViewModelFactory(private val context: Context) : ViewModelProvider.Factory { +class ReceiptViewModelFactory(private val application: Application) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(ReceiptViewModel::class.java)) { @Suppress("UNCHECKED_CAST") - return ReceiptViewModel(context) as T + return ReceiptViewModel(application) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/Mobile/gradle/libs.versions.toml b/Mobile/gradle/libs.versions.toml index 18ba0e9..cd1e6b8 100644 --- a/Mobile/gradle/libs.versions.toml +++ b/Mobile/gradle/libs.versions.toml @@ -10,6 +10,7 @@ lifecycleRuntimeCompose = "2.8.0" navigationCompose = "2.9.5" material3 = "1.4.0" foundation = "1.9.4" +foundationVersion = "1.10.0" [libraries] androidx-activity-compose-v193 = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -31,6 +32,7 @@ compose-foundation = { module = "androidx.compose.foundation:foundation", versio compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } +androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }