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/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..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) @@ -107,4 +108,21 @@ object RetrofitClient { retrofit.create(CategoryApiService::class.java) } + + 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() + .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..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 @@ -1,31 +1,43 @@ 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 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.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.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color 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.compose.ui.zIndex 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,78 +46,49 @@ import java.util.UUID @Composable fun ReceiptScreen() { val context = LocalContext.current - val receipts = remember { - mutableStateListOf() - } + 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 */ } + val vm: ReceiptViewModel = viewModel( + factory = ReceiptViewModelFactory(context.applicationContext as Application) ) - // Camera launcher (returns a Bitmap preview) and forwards to current handler + val uiState by vm.uiState.collectAsState() + val scannedItems by vm.scannedItems.collectAsState() + 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) + } } ) - 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", - style = MaterialTheme.typography.titleLarge - ) - } + title = { 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( @@ -114,314 +97,351 @@ fun ReceiptScreen() { .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - if (receipts.isEmpty()) { + + Column( + modifier = Modifier.fillMaxSize() + ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.weight(1f).fillMaxWidth(), 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) - ) + when (uiState) { + is ReceiptUiState.Idle -> EmptyStateContent() + is ReceiptUiState.Loading -> { } + + is ReceiptUiState.Reviewing -> { + if (scannedItems.isNotEmpty()) { + ReceiptReviewDialog( + items = scannedItems, + onConfirm = { selectedItems -> + scope.launch { vm.saveExpenses(selectedItems) } + }, + 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() } + ) + } } } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Top - ) { - items(receipts) { receipt -> - ReceiptRow( - title = receipt.merchant, - subtitle = receipt.category, - amount = receipt.amount, - thumbnailUrl = receipt.thumbnailUrl + + 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) } } } - } - 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 + if (uiState is ReceiptUiState.Loading) { + val message = (uiState as ReceiptUiState.Loading).message + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)) + .zIndex(2f), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 6.dp ) - ) - showAddDialog = false + Spacer(Modifier.height(24.dp)) + Text( + text = message, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + } } - ) + } } } } - -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: (List) -> Unit, + onCancel: () -> Unit ) { - Dialog(onDismissRequest = onDismiss) { - Surface( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(16.dp), + val selectedItems = remember { mutableStateListOf().apply { addAll(items) } } + + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.85f) + .padding(16.dp), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) ) { - Column( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = "Review Items", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "${selectedItems.size} of ${items.size} items selected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - TextButton(onClick = onDismiss) { - Text("✕", color = MaterialTheme.colorScheme.onBackground) - } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text( - text = "New Receipt", - color = MaterialTheme.colorScheme.onBackground + items(items) { item -> + val isSelected = selectedItems.contains(item) + SelectableItemRow( + item = item, + isSelected = isSelected, + onToggle = { + if (isSelected) selectedItems.remove(item) + else selectedItems.add(item) + } ) } - Spacer(modifier = Modifier.size(32.dp)) } - // Inputs - val merchant = remember { mutableStateOf("") } - val category = remember { mutableStateOf("") } - val amountText = remember { mutableStateOf("") } - val thumbnail = remember { mutableStateOf(null) } + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) - Column( - modifier = Modifier - .padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + val total = selectedItems.sumOf { it.price } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - 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() - ) - - 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() + Text("Total Selected", style = MaterialTheme.typography.titleMedium) + Text( + "$${String.format("%.2f", total)}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold ) + } - 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() - ) + Spacer(Modifier.height(24.dp)) - // Thumbnail preview + Scan button - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onCancel) { Text("Cancel") } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { onConfirm(selectedItems.toList()) }, + enabled = selectedItems.isNotEmpty() ) { - 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") - } + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Save (${selectedItems.size})") } } + } + } + } +} - 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 - ) - ) { - Text("Add receipt") - } +@Composable +fun SelectableItemRow( + item: ProcessedItem, + isSelected: Boolean, + onToggle: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() } + .background( + if (isSelected) MaterialTheme.colorScheme.surfaceContainerLow else Color.Transparent, + RoundedCornerShape(12.dp) + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 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( + Icons.Default.ShoppingBag, + contentDescription = null, + tint = if(isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + 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 = 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 + ) + } + + Text( + text = "$${String.format("%.2f", item.price)}", + style = MaterialTheme.typography.titleMedium, + 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 + ) } } @Composable -private fun ReceiptRow( +fun ResultAlertDialog( title: String, - subtitle: String, - amount: Double, - thumbnailUrl: 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) } + ) +} + +@Composable +fun ScannedItemRow(item: ProcessedItem) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), + .background(MaterialTheme.colorScheme.surfaceContainerLow, RoundedCornerShape(12.dp)) + .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { Surface( shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(40.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( + Icons.Default.ShoppingBag, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp) + ) } } - 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, + fontWeight = FontWeight.SemiBold + ) + Text( + text = item.category, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary ) } Text( - text = "$${"%.2f".format(amount)}", - color = MaterialTheme.colorScheme.onSurface + 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, 80, out) + } + 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 new file mode 100644 index 0000000..660b49f --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt @@ -0,0 +1,178 @@ +package com.example.budgeting.android.ui.viewmodels + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +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 +import java.util.UUID + +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(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 + + 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) { + viewModelScope.launch { + _uiState.value = ReceiptUiState.Loading("Analyzing receipt with AI...") + + try { + val file = withContext(Dispatchers.IO) { + createTempFileFromUri(uri) + } + + 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) + + 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()}") + } + + file.delete() + + } catch (e: Exception) { + _uiState.value = ReceiptUiState.Error("Error: ${e.message}") + } + } + } + + fun saveExpenses(itemsToSave: List) { + viewModelScope.launch { + _uiState.value = ReceiptUiState.Loading("Saving expenses...") + + try { + val initialCategories = try { + categoryRepository.getCategories(null, null) + } catch (e: Exception) { emptyList() } + + val localCategoryCache = initialCategories.toMutableList() + var successCount = 0 + + withContext(Dispatchers.IO) { + itemsToSave.forEach { item -> + + var targetCategory = localCategoryCache.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() + } + } + + 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() + } + } + } + } + + _uiState.value = ReceiptUiState.Success("Successfully saved $successCount expenses!") + _scannedItems.value = emptyList() + + } catch (e: Exception) { + _uiState.value = ReceiptUiState.Error("Failed to save: ${e.message}") + } + } + } + + 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 new file mode 100644 index 0000000..32eca6a --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModelFactory.kt @@ -0,0 +1,14 @@ +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.budgeting.android.ui.viewmodels.ReceiptViewModel + +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(application) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file 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" }