diff --git a/.gitignore b/.gitignore index 347e252..ca1b2d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# macOS +.DS_Store + # Gradle files .gradle/ build/ @@ -28,6 +31,7 @@ render.experimental.xml # Google Services (e.g. APIs or Firebase) google-services.json +secrets.properties # Android Profiling *.hprof diff --git a/app/build.gradle b/app/build.gradle index a7956bb..0fa5a49 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -92,6 +92,7 @@ dependencies { implementation libs.androidx.annotation implementation libs.android.libraries.places.places implementation libs.maps.android.places.places.ktx + implementation libs.accompanist.permissions // Kotlin Dependencies implementation platform(libs.jetbrains.kotlin.bom) diff --git a/app/src/main/assets/database/predefined_credit_cards.db b/app/src/main/assets/database/predefined_credit_cards.db index ab075df..2cdd88c 100644 Binary files a/app/src/main/assets/database/predefined_credit_cards.db and b/app/src/main/assets/database/predefined_credit_cards.db differ diff --git a/app/src/main/java/edu/card/clarity/MainActivity.kt b/app/src/main/java/edu/card/clarity/MainActivity.kt index 83bfbbe..a6e6897 100644 --- a/app/src/main/java/edu/card/clarity/MainActivity.kt +++ b/app/src/main/java/edu/card/clarity/MainActivity.kt @@ -2,7 +2,6 @@ package edu.card.clarity import android.Manifest import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity @@ -10,10 +9,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.registerForActivityResult import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationManagerCompat import dagger.hilt.android.AndroidEntryPoint +import edu.card.clarity.presentation.MainScreen import edu.card.clarity.ui.theme.CardClarityTheme @AndroidEntryPoint diff --git a/app/src/main/java/edu/card/clarity/presentation/MainActivity.kt b/app/src/main/java/edu/card/clarity/presentation/MainActivity.kt deleted file mode 100644 index 90c2582..0000000 --- a/app/src/main/java/edu/card/clarity/presentation/MainActivity.kt +++ /dev/null @@ -1,47 +0,0 @@ -package edu.card.clarity.presentation - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import edu.card.clarity.ui.theme.CardClarityTheme - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - CardClarityTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } - } - } - } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - CardClarityTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/edu/card/clarity/MainScreen.kt b/app/src/main/java/edu/card/clarity/presentation/MainScreen.kt similarity index 98% rename from app/src/main/java/edu/card/clarity/MainScreen.kt rename to app/src/main/java/edu/card/clarity/presentation/MainScreen.kt index e8f3072..246d3e5 100644 --- a/app/src/main/java/edu/card/clarity/MainScreen.kt +++ b/app/src/main/java/edu/card/clarity/presentation/MainScreen.kt @@ -1,4 +1,4 @@ -package edu.card.clarity +package edu.card.clarity.presentation import android.annotation.SuppressLint import androidx.compose.foundation.layout.RowScope diff --git a/app/src/main/java/edu/card/clarity/presentation/Purchase.kt b/app/src/main/java/edu/card/clarity/presentation/Purchase.kt deleted file mode 100644 index ae1b145..0000000 --- a/app/src/main/java/edu/card/clarity/presentation/Purchase.kt +++ /dev/null @@ -1,34 +0,0 @@ -package edu.card.clarity.presentation - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview - -@Composable -fun PurchaseScreen() { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black), - contentAlignment = Alignment.Center - ) { - Text( - text = "PURCHASE", - fontWeight = FontWeight.Bold, - color = Color.White - ) - } -} - -@Composable -@Preview -fun PurchaseScreenPreview() { - PurchaseScreen() -} \ No newline at end of file diff --git a/app/src/main/java/edu/card/clarity/presentation/navigation/BottomNavGraph.kt b/app/src/main/java/edu/card/clarity/presentation/navigation/BottomNavGraph.kt index 4d98530..601ce60 100644 --- a/app/src/main/java/edu/card/clarity/presentation/navigation/BottomNavGraph.kt +++ b/app/src/main/java/edu/card/clarity/presentation/navigation/BottomNavGraph.kt @@ -6,14 +6,16 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument -import edu.card.clarity.presentation.PurchaseScreen -import edu.card.clarity.presentation.addBenefitScreen.AddBenefitScreen +import edu.card.clarity.enums.PurchaseType import edu.card.clarity.presentation.addCardScreen.AddCardScreen +import edu.card.clarity.presentation.addBenefitScreen.AddBenefitScreen +import edu.card.clarity.presentation.upcomingPaymentsScreen.UpcomingPaymentsScreen import edu.card.clarity.presentation.homeScreen.HomeScreen import edu.card.clarity.presentation.myBenefitsScreen.MyBenefitsScreen import edu.card.clarity.presentation.myCardScreen.MyCardsScreen import edu.card.clarity.presentation.myReceiptsScreen.MyReceiptsScreen -import edu.card.clarity.presentation.upcomingPaymentsScreen.UpcomingPaymentsScreen +import edu.card.clarity.presentation.purchaseBenefitsScreen.PurchaseOptimalBenefitsScreen +import edu.card.clarity.presentation.purchaseBenefitsScreen.PurchaseScreen import edu.card.clarity.presentation.utils.ArgumentNames import edu.card.clarity.presentation.utils.Destinations @@ -33,7 +35,7 @@ fun BottomNavGraph(navController: NavHostController) { MyReceiptsScreen() } composable(Destinations.PURCHASE) { - PurchaseScreen() + PurchaseScreen(navController) } composable(Destinations.MY_CARDS) { MyCardsScreen(navController) @@ -89,5 +91,13 @@ fun BottomNavGraph(navController: NavHostController) { AddBenefitScreen(cardName, navController) } + composable( + route = "${Destinations.PURCHASE_OPTIMAL_BENEFITS}/{category}", + arguments = listOf(navArgument("category") { type = NavType.StringType }) + ) { backStackEntry -> + val categoryString = backStackEntry.arguments?.getString("category")!! + val category = PurchaseType.valueOf(categoryString) + PurchaseOptimalBenefitsScreen(navController, category) + } } } \ No newline at end of file diff --git a/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/GeolocationPopup.kt b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/GeolocationPopup.kt new file mode 100644 index 0000000..b661445 --- /dev/null +++ b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/GeolocationPopup.kt @@ -0,0 +1,116 @@ +package edu.card.clarity.presentation.purchaseBenefitsScreen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import edu.card.clarity.location.GeolocationInference + +@Composable +fun GeolocationPopup( + geolocationInference: GeolocationInference?, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (geolocationInference != null) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Nearby Merchant Detected", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp) + ) + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(vertical = 16.dp) + ) { + Text( + text = "We detected that you're near ", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) + ) + Text( + text = geolocationInference.merchantName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Would you explore benefits in ${geolocationInference.purchaseType}?", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) + ) + } + }, + confirmButton = { + Button( + onClick = onConfirm, + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) { + Text("Yes") + } + }, + dismissButton = { + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) { + Text("No") + } + }, + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 8.dp + ) + } else { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Loading", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp) + ) + }, + text = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp) + ) { + CircularProgressIndicator() + } + }, + confirmButton = { + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) { + Text("Dismiss") + } + }, + dismissButton = {}, + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 8.dp + ) + } +} diff --git a/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/GeolocationViewModel.kt b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/GeolocationViewModel.kt new file mode 100644 index 0000000..13ad6e6 --- /dev/null +++ b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/GeolocationViewModel.kt @@ -0,0 +1,33 @@ +package edu.card.clarity.presentation.purchaseBenefitsScreen + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import edu.card.clarity.location.GeolocationInference +import edu.card.clarity.location.GeolocationInferenceService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GeolocationViewModel @Inject constructor( + private val geolocationInferenceService: GeolocationInferenceService +) : ViewModel() { + + private val _geolocationInference = MutableStateFlow?>(null) + val geolocationInference: StateFlow?> = _geolocationInference + + fun fetchGeolocationInference() { + viewModelScope.launch { + try { + val inferences = geolocationInferenceService.getPurchaseTypeInference() + Log.d("Inferences", inferences.toString()) + _geolocationInference.value = inferences + } catch (e: SecurityException) { + // Handle the case where permissions were not granted + } + } + } +} diff --git a/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/Purchase.kt b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/Purchase.kt new file mode 100644 index 0000000..1877e56 --- /dev/null +++ b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/Purchase.kt @@ -0,0 +1,189 @@ +package edu.card.clarity.presentation.purchaseBenefitsScreen + +import android.Manifest +import androidx.compose.foundation.Image +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.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import edu.card.clarity.R +import edu.card.clarity.enums.PurchaseType +import edu.card.clarity.presentation.utils.Destinations + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun PurchaseScreen(navController: NavController, geolocationViewModel: GeolocationViewModel = hiltViewModel()) { + val locationPermissionsState = rememberMultiplePermissionsState( + permissions = listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + + LaunchedEffect(Unit) { + locationPermissionsState.launchMultiplePermissionRequest() + } + + var showDialog by remember { mutableStateOf(false) } + val geolocationInference by geolocationViewModel.geolocationInference.collectAsState() + + // Wait for permissions to be granted before fetching inferences + LaunchedEffect(locationPermissionsState.allPermissionsGranted) { + if (locationPermissionsState.allPermissionsGranted) { + geolocationViewModel.fetchGeolocationInference() + showDialog = true + } + } + + if (showDialog && locationPermissionsState.allPermissionsGranted) { + GeolocationPopup( + geolocationInference = geolocationInference?.firstOrNull(), + onDismiss = { showDialog = false }, + onConfirm = { + showDialog = false + geolocationInference?.firstOrNull()?.purchaseType?.let { purchaseType -> + navController.navigate("${Destinations.PURCHASE_OPTIMAL_BENEFITS}/${purchaseType.name}") + } + } + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(horizontal = 32.dp, vertical = 40.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top + ) { + Text( + text = "Best Card for Every Purchase", + fontWeight = FontWeight.Bold, + fontSize = 26.sp, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Categories:", + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + color = Color.Gray + ) + + Spacer(modifier = Modifier.height(24.dp)) + + CategoryGrid(navController) + } +} + +@Composable +fun CategoryGrid(navController: NavController) { + val categories = PurchaseType.entries + val displayNames = mapOf( + PurchaseType.HomeImprovement to "Home Improvement" + ) + + LazyColumn { + items(categories.chunked(2)) { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + for (category in rowItems) { + CategoryCard(category = category, displayNames) { + navController.navigate("${Destinations.PURCHASE_OPTIMAL_BENEFITS}/${category.name}") + } + } + if (rowItems.size == 1) { + Spacer(modifier = Modifier.size(150.dp)) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + item { + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +fun CategoryCard(category: PurchaseType, displayNames: Map, onClick: () -> Unit) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(8.dp) + .shadow(4.dp, RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface) + .size(150.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + val imageRes = when (category) { + PurchaseType.Pharmacy -> R.drawable.pharmacy + PurchaseType.Entertainment -> R.drawable.entertainment + PurchaseType.Furniture -> R.drawable.furniture + PurchaseType.Gas -> R.drawable.gas + PurchaseType.Hotel -> R.drawable.hotel + PurchaseType.HomeImprovement -> R.drawable.home_improvement + PurchaseType.Groceries -> R.drawable.groceries + PurchaseType.Restaurants -> R.drawable.restaurants + PurchaseType.Travel -> R.drawable.travel + PurchaseType.Others -> R.drawable.others + } + Surface( + modifier = Modifier.size(100.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) { + Image( + painter = painterResource(id = imageRes), + contentDescription = category.name, + modifier = Modifier.padding(20.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = displayNames[category] ?: category.name, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } +} + +@Composable +@Preview +fun PurchaseScreenPreview() { + val navController = rememberNavController() + PurchaseScreen(navController) +} diff --git a/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/PurchaseOptimalBenefitsScreen.kt b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/PurchaseOptimalBenefitsScreen.kt new file mode 100644 index 0000000..325718b --- /dev/null +++ b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/PurchaseOptimalBenefitsScreen.kt @@ -0,0 +1,164 @@ +package edu.card.clarity.presentation.purchaseBenefitsScreen + +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.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import edu.card.clarity.enums.PurchaseType + +@Composable +fun PurchaseOptimalBenefitsScreen( + navController: NavController, + category: PurchaseType, + viewModel: PurchaseOptimalBenefitsScreenViewModel = hiltViewModel() +) { + val creditCards by viewModel.creditCards.collectAsState() + val optimalCreditCard by viewModel.optimalCreditCard.collectAsState() + val optimalCardMessage by viewModel.optimalCardMessage.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(horizontal = 16.dp, vertical = 20.dp) + ) { + Text( + text = "Best Card for ${category.name}", + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 16.dp) + ) + + if (optimalCreditCard != null) { + OptimalCreditCardItem(optimalCreditCard!!, category) + Spacer(modifier = Modifier.height(16.dp)) + } else { + Text( + text = optimalCardMessage ?: "", + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + color = Color.Red, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + Text( + text = "Other Available Benefits for ${category.name}", + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 16.dp) + ) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + items(creditCards) { card -> + CreditCardItem(card) + } + item { + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { /* Navigate to record receipt screen */ }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Text(text = "Record a Receipt") + } + } + } + } +} + +@Composable +fun OptimalCreditCardItem(card: PurchaseOptimalBenefitsScreenViewModel.CreditCardItemUiState, category: PurchaseType) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .shadow(8.dp, shape = MaterialTheme.shapes.medium), + elevation = CardDefaults.elevatedCardElevation(4.dp), + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) + ) { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + + card.rewards.forEach { reward -> + Text( + text = "${reward.purchaseType} - ${reward.description}", + fontSize = 16.sp, + color = Color.Black + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + } + } +} + +@Composable +fun CreditCardItem(card: PurchaseOptimalBenefitsScreenViewModel.CreditCardItemUiState) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .shadow(8.dp, shape = MaterialTheme.shapes.medium), + elevation = CardDefaults.elevatedCardElevation(4.dp), + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp) + ) { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + card.rewards.forEach { reward -> + Text( + text = "${reward.purchaseType} - ${reward.description}", + fontSize = 16.sp, + color = Color.Black + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + } + } +} + +@Composable +@Preview +fun BenefitsScreenPreview() { + val navController = rememberNavController() + PurchaseOptimalBenefitsScreen(navController, category = PurchaseType.Pharmacy) +} diff --git a/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/PurchaseOptimalBenefitsScreenViewModel.kt b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/PurchaseOptimalBenefitsScreenViewModel.kt new file mode 100644 index 0000000..53b5310 --- /dev/null +++ b/app/src/main/java/edu/card/clarity/presentation/purchaseBenefitsScreen/PurchaseOptimalBenefitsScreenViewModel.kt @@ -0,0 +1,171 @@ +package edu.card.clarity.presentation.purchaseBenefitsScreen + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import edu.card.clarity.domain.Purchase +import edu.card.clarity.enums.PurchaseType +import edu.card.clarity.enums.RewardType +import edu.card.clarity.repositories.creditCard.CashBackCreditCardRepository +import edu.card.clarity.repositories.creditCard.PointBackCreditCardRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.util.* +import javax.inject.Inject + +@HiltViewModel +class PurchaseOptimalBenefitsScreenViewModel @Inject constructor( + private val cashBackCreditCardRepository: CashBackCreditCardRepository, + private val pointBackCreditCardRepository: PointBackCreditCardRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val category: PurchaseType = PurchaseType.valueOf(savedStateHandle.get("category") ?: "") + + private val _creditCards = MutableStateFlow>(emptyList()) + val creditCards: StateFlow> = _creditCards + + private val _optimalCreditCard = MutableStateFlow(null) + val optimalCreditCard: StateFlow = _optimalCreditCard + + private val _optimalCardMessage = MutableStateFlow(null) + val optimalCardMessage: StateFlow = _optimalCardMessage + + init { + fetchCreditCards(category) + findOptimalCreditCard(category) + } + + private fun fetchCreditCards(category: PurchaseType) { + viewModelScope.launch { + val cashBackCardsFlow = cashBackCreditCardRepository.getAllCreditCardsStream().map { cards -> + cards.flatMap { card -> + val rewardsForCategory = card.purchaseRewards.filter { reward -> + reward.applicablePurchaseType == category + } + + val rewards = rewardsForCategory.ifEmpty { + card.purchaseRewards.filter { reward -> + reward.applicablePurchaseType == PurchaseType.Others + } + } + + rewards.map { reward -> + CreditCardItemUiState( + name = card.info.name, + rewards = listOf( + RewardUiState( + purchaseType = reward.applicablePurchaseType.name, + description = "${(reward.rewardFactor * 100).toInt()}% Cashback" + ) + ) + ) + } + } + } + + val pointBackCardsFlow = pointBackCreditCardRepository.getAllCreditCardsStream().map { cards -> + cards.flatMap { card -> + val rewardsForCategory = card.purchaseRewards.filter { reward -> + reward.applicablePurchaseType == category + } + + val rewards = rewardsForCategory.ifEmpty { + card.purchaseRewards.filter { reward -> + reward.applicablePurchaseType == PurchaseType.Others + } + } + + rewards.map { reward -> + CreditCardItemUiState( + name = card.info.name, + rewards = listOf( + RewardUiState( + purchaseType = reward.applicablePurchaseType.name, + description = "${reward.rewardFactor}x Points" + ) + ) + ) + } + } + } + + combine(cashBackCardsFlow, pointBackCardsFlow) { cashBackCards, pointBackCards -> + cashBackCards + pointBackCards + }.collect { combinedCards -> + _creditCards.value = combinedCards + } + } + } + + private fun findOptimalCreditCard(category: PurchaseType) { + viewModelScope.launch { + val dummyPurchase = Purchase( + id = UUID.randomUUID(), + time = Date(), + merchant = "Dummy Merchant", + type = category, + total = 100f, + rewardAmount = 0f, + creditCardId = UUID.randomUUID() + ) + + val optimalCashBackCard = try { + cashBackCreditCardRepository.findOptimalCreditCard(dummyPurchase) + } catch (e: NoSuchElementException) { + null + } + val optimalPointBackCard = try { + pointBackCreditCardRepository.findOptimalCreditCard(dummyPurchase) + } catch (e: NoSuchElementException) { + null + } + + val optimalCard = listOfNotNull(optimalCashBackCard, optimalPointBackCard).maxByOrNull { + it.getReturnAmountInCash(dummyPurchase) + } + + // The Optimal Card's corresponding benefit that makes it optimal + optimalCard?.let { card -> + val rewardsForCategory = card.purchaseRewards.filter { reward -> + reward.applicablePurchaseType == category + } + + val rewards = rewardsForCategory.ifEmpty { + card.purchaseRewards.filter { reward -> + reward.applicablePurchaseType == PurchaseType.Others + } + } + + val uiState = CreditCardItemUiState( + name = card.info.name, + rewards = rewards.map { reward -> + RewardUiState( + purchaseType = reward.applicablePurchaseType.name, + description = if (card.info.rewardType == RewardType.CashBack) { + "${(reward.rewardFactor * 100).toInt()}% Cashback" + } else { + "${reward.rewardFactor}x Points" + } + ) + } + ) + _optimalCreditCard.value = uiState + } + } + } + + data class CreditCardItemUiState( + val name: String, + val rewards: List + ) + + data class RewardUiState( + val purchaseType: String, + val description: String + ) +} diff --git a/app/src/main/java/edu/card/clarity/presentation/utils/Destinations.kt b/app/src/main/java/edu/card/clarity/presentation/utils/Destinations.kt index ad1552d..bee16d6 100644 --- a/app/src/main/java/edu/card/clarity/presentation/utils/Destinations.kt +++ b/app/src/main/java/edu/card/clarity/presentation/utils/Destinations.kt @@ -9,4 +9,5 @@ internal object Destinations { const val UPCOMING_PAYMENTS = "upcomingPayments" const val MY_BENEFITS = "myBenefits" const val ADD_BENEFIT = "addBenefit" + const val PURCHASE_OPTIMAL_BENEFITS = "purchaseOptimalBenefits" } \ No newline at end of file diff --git a/app/src/main/res/drawable/entertainment.xml b/app/src/main/res/drawable/entertainment.xml new file mode 100644 index 0000000..ba7b2c8 --- /dev/null +++ b/app/src/main/res/drawable/entertainment.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/furniture.xml b/app/src/main/res/drawable/furniture.xml new file mode 100644 index 0000000..f0e27d6 --- /dev/null +++ b/app/src/main/res/drawable/furniture.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/gas.xml b/app/src/main/res/drawable/gas.xml new file mode 100644 index 0000000..bd0b3a1 --- /dev/null +++ b/app/src/main/res/drawable/gas.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/groceries.xml b/app/src/main/res/drawable/groceries.xml new file mode 100644 index 0000000..cf2bcf7 --- /dev/null +++ b/app/src/main/res/drawable/groceries.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/home_improvement.xml b/app/src/main/res/drawable/home_improvement.xml new file mode 100644 index 0000000..bb90a3a --- /dev/null +++ b/app/src/main/res/drawable/home_improvement.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/hotel.xml b/app/src/main/res/drawable/hotel.xml new file mode 100644 index 0000000..4b624ed --- /dev/null +++ b/app/src/main/res/drawable/hotel.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/others.xml b/app/src/main/res/drawable/others.xml new file mode 100644 index 0000000..39d4284 --- /dev/null +++ b/app/src/main/res/drawable/others.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/pharmacy.xml b/app/src/main/res/drawable/pharmacy.xml new file mode 100644 index 0000000..41cc005 --- /dev/null +++ b/app/src/main/res/drawable/pharmacy.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/restaurants.xml b/app/src/main/res/drawable/restaurants.xml new file mode 100644 index 0000000..8f2b539 --- /dev/null +++ b/app/src/main/res/drawable/restaurants.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/travel.xml b/app/src/main/res/drawable/travel.xml new file mode 100644 index 0000000..223b2ba --- /dev/null +++ b/app/src/main/res/drawable/travel.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b2fa77..0d2bc7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +accompanistPermissions = "0.31.1-alpha" agp = "8.5.1" coreTesting = "2.2.0" kotlin = "2.0.0" @@ -28,6 +29,7 @@ secrets = "2.0.1" [libraries] # Android +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "uiTextGoogleFonts" }