From 2d1743644805b2f6e0a75faaf316c6deb5928753 Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Wed, 23 Oct 2024 17:21:59 -0400 Subject: [PATCH 001/126] notifications navigation setup --- .../ui/navigation/MainTabbedNavigation.kt | 22 ++++++++++ .../eatery/ui/navigation/NavigationItem.kt | 1 + .../eatery/ui/screens/NotificationsScreen.kt | 31 +++++++++++++ .../eatery/ui/screens/SettingsScreen.kt | 44 ++++++++++++++----- 4 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsScreen.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt index 328912a9..431b3f45 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt @@ -36,6 +36,7 @@ import com.cornellappdev.android.eatery.ui.screens.FirstTimeShown import com.cornellappdev.android.eatery.ui.screens.HomeScreen import com.cornellappdev.android.eatery.ui.screens.LegalScreen import com.cornellappdev.android.eatery.ui.screens.NearestScreen +import com.cornellappdev.android.eatery.ui.screens.NotificationsScreen import com.cornellappdev.android.eatery.ui.screens.OnboardingScreen import com.cornellappdev.android.eatery.ui.screens.PrivacyScreen import com.cornellappdev.android.eatery.ui.screens.ProfileScreen @@ -74,6 +75,10 @@ fun NavigationSetup(hasOnboarded: Boolean) { showBottomBar.value = true } + Routes.NOTIFICATIONS.route -> { + showBottomBar.value = true + } + Routes.PRIVACY.route -> { showBottomBar.value = false } @@ -301,6 +306,7 @@ fun SetupNavHost( destinations = hashMapOf( Routes.ABOUT to { navController.navigate(Routes.ABOUT.route) }, Routes.FAVORITES to { navController.navigate(Routes.FAVORITES.route) }, + Routes.NOTIFICATIONS to { navController.navigate(Routes.NOTIFICATIONS.route) }, Routes.LEGAL to { navController.navigate(Routes.LEGAL.route) }, Routes.PRIVACY to { navController.navigate(Routes.PRIVACY.route) }, Routes.SUPPORT to { navController.navigate(Routes.SUPPORT.route) }, @@ -366,6 +372,22 @@ fun SetupNavHost( }) } + composable( + route = Routes.NOTIFICATIONS.route, + enterTransition = { + fadeIn( + initialAlpha = 0f, + animationSpec = tween(durationMillis = 500) + ) + }, + exitTransition = { + fadeOut( + animationSpec = tween(durationMillis = 500) + ) + }) { + NotificationsScreen() + } + composable( route = Routes.NEAREST.route, enterTransition = { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt index c6bc7e89..547d3901 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt @@ -85,6 +85,7 @@ enum class Routes(override var route: String) : NavUnit { ACCOUNT("account"), ABOUT("about"), FAVORITES("favorites"), + NOTIFICATIONS("favorites"), NEAREST("nearest"), LEGAL("legal"), PRIVACY("privacy"), diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsScreen.kt new file mode 100644 index 00000000..e49f6bca --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsScreen.kt @@ -0,0 +1,31 @@ +package com.cornellappdev.android.eatery.ui.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.cornellappdev.android.eatery.ui.theme.EateryBlue +import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography +import com.google.firebase.messaging.RemoteMessage.Notification + +@Composable +fun NotificationsScreen( + +){ + Column( + modifier = Modifier + .padding(top = 36.dp, start = 16.dp, end = 16.dp) + .fillMaxSize() + ) { + Text( + text = "Notifications", + color = EateryBlue, + style = EateryBlueTypography.h2, + modifier = Modifier.padding(top = 7.dp) + ) + } +} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt index bf4a74bc..72f56290 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt @@ -105,6 +105,30 @@ fun SettingsScreen( destinations[Routes.ABOUT]?.invoke() } ) + SettingsOption( + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_appicon_settings), + contentDescription = null, + tint = GrayFive, + modifier = Modifier.size(24.dp) + ) + }, + title = "App Icon", + description = "Select the Eatery app icon for your phone", + onClick = { + coroutineScope.launch { + modalBottomSheetState.show() + } + }, + trailingIcon = { + Text( + text = "Change", + style = EateryBlueTypography.button, + color = EateryBlue, + ) + } + ) SettingsLineSeparator() SettingsOption( leadingIcon = { @@ -132,26 +156,24 @@ fun SettingsScreen( SettingsOption( leadingIcon = { Icon( - painter = painterResource(id = R.drawable.ic_appicon_settings), + painter = painterResource(id = R.drawable.ic_bell), contentDescription = null, tint = GrayFive, modifier = Modifier.size(24.dp) ) }, - title = "App Icon", - description = "Select the Eatery app icon for your phone", + title = "Notifications", + description = "Manage item and promotional notifications", onClick = { - coroutineScope.launch { - modalBottomSheetState.show() - } + destinations[Routes.NOTIFICATIONS]?.invoke() }, trailingIcon = { - Text( - text = "Change", - style = EateryBlueTypography.button, - color = EateryBlue, + Icon( + imageVector = Icons.Outlined.ChevronRight, + contentDescription = null, + tint = EateryBlue, ) - } + }, ) SettingsLineSeparator() SettingsOption( From 4883aa54bab2887258b1558e336b870340662120 Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Wed, 23 Oct 2024 17:52:59 -0400 Subject: [PATCH 002/126] notification screen name change --- .../ui/navigation/MainTabbedNavigation.kt | 13 ++-- .../eatery/ui/navigation/NavigationItem.kt | 2 +- .../eatery/ui/screens/NotificationsScreen.kt | 31 --------- .../ui/screens/NotificationsSettingsScreen.kt | 63 +++++++++++++++++++ .../eatery/ui/screens/SettingsScreen.kt | 2 +- 5 files changed, 71 insertions(+), 40 deletions(-) delete mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsScreen.kt create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt index 431b3f45..d6b070d9 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt @@ -36,7 +36,7 @@ import com.cornellappdev.android.eatery.ui.screens.FirstTimeShown import com.cornellappdev.android.eatery.ui.screens.HomeScreen import com.cornellappdev.android.eatery.ui.screens.LegalScreen import com.cornellappdev.android.eatery.ui.screens.NearestScreen -import com.cornellappdev.android.eatery.ui.screens.NotificationsScreen +import com.cornellappdev.android.eatery.ui.screens.NotificationsSettingsScreen import com.cornellappdev.android.eatery.ui.screens.OnboardingScreen import com.cornellappdev.android.eatery.ui.screens.PrivacyScreen import com.cornellappdev.android.eatery.ui.screens.ProfileScreen @@ -45,7 +45,6 @@ import com.cornellappdev.android.eatery.ui.screens.SettingsScreen import com.cornellappdev.android.eatery.ui.screens.SupportScreen import com.cornellappdev.android.eatery.ui.screens.UpcomingMenuScreen import com.cornellappdev.android.eatery.ui.theme.EateryBlue -import com.cornellappdev.android.eatery.ui.viewmodels.CompareMenusViewModel import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel import com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.composable @@ -75,8 +74,8 @@ fun NavigationSetup(hasOnboarded: Boolean) { showBottomBar.value = true } - Routes.NOTIFICATIONS.route -> { - showBottomBar.value = true + Routes.NOTIFICATIONS_SETTING.route -> { + showBottomBar.value = false } Routes.PRIVACY.route -> { @@ -306,7 +305,7 @@ fun SetupNavHost( destinations = hashMapOf( Routes.ABOUT to { navController.navigate(Routes.ABOUT.route) }, Routes.FAVORITES to { navController.navigate(Routes.FAVORITES.route) }, - Routes.NOTIFICATIONS to { navController.navigate(Routes.NOTIFICATIONS.route) }, + Routes.NOTIFICATIONS_SETTING to { navController.navigate(Routes.NOTIFICATIONS_SETTING.route) }, Routes.LEGAL to { navController.navigate(Routes.LEGAL.route) }, Routes.PRIVACY to { navController.navigate(Routes.PRIVACY.route) }, Routes.SUPPORT to { navController.navigate(Routes.SUPPORT.route) }, @@ -373,7 +372,7 @@ fun SetupNavHost( } composable( - route = Routes.NOTIFICATIONS.route, + route = Routes.NOTIFICATIONS_SETTING.route, enterTransition = { fadeIn( initialAlpha = 0f, @@ -385,7 +384,7 @@ fun SetupNavHost( animationSpec = tween(durationMillis = 500) ) }) { - NotificationsScreen() + NotificationsSettingsScreen() } composable( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt index 547d3901..8abf7d4d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt @@ -85,7 +85,7 @@ enum class Routes(override var route: String) : NavUnit { ACCOUNT("account"), ABOUT("about"), FAVORITES("favorites"), - NOTIFICATIONS("favorites"), + NOTIFICATIONS_SETTING("notifications_setting"), NEAREST("nearest"), LEGAL("legal"), PRIVACY("privacy"), diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsScreen.kt deleted file mode 100644 index e49f6bca..00000000 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsScreen.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.cornellappdev.android.eatery.ui.screens - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.cornellappdev.android.eatery.ui.theme.EateryBlue -import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography -import com.google.firebase.messaging.RemoteMessage.Notification - -@Composable -fun NotificationsScreen( - -){ - Column( - modifier = Modifier - .padding(top = 36.dp, start = 16.dp, end = 16.dp) - .fillMaxSize() - ) { - Text( - text = "Notifications", - color = EateryBlue, - style = EateryBlueTypography.h2, - modifier = Modifier.padding(top = 7.dp) - ) - } -} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt new file mode 100644 index 00000000..bbccf0be --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt @@ -0,0 +1,63 @@ +package com.cornellappdev.android.eatery.ui.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cornellappdev.android.eatery.ui.components.settings.SwitchOption +import com.cornellappdev.android.eatery.ui.theme.EateryBlue +import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography +import com.cornellappdev.android.eatery.ui.theme.GraySix +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.messaging.RemoteMessage.Notification + +@Composable +fun NotificationsSettingsScreen( + +){ + Column( + modifier = Modifier + .padding(top = 36.dp, start = 16.dp, end = 16.dp) + .fillMaxSize() + ) { + Text( + text = "Notifications", + color = EateryBlue, + style = EateryBlueTypography.h2, + modifier = Modifier.padding(top = 7.dp) + ) + + Text( + text = "Manage item and promotional notifications", + style = TextStyle(fontWeight = FontWeight.Medium, fontSize = 18.sp), + color = GraySix, + modifier = Modifier.padding(top = 7.dp, bottom = 12.dp) + ) + SwitchOption( + title = "Pause all notifications", + description = "", + initialValue = false, + onCheckedChange = { + } + ) + SwitchOption( + title = "Favorite item notifications", + description = "Get notified when favorite items are served", + initialValue = false, + onCheckedChange = { + + } + ) + + + } +} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt index 72f56290..dafd8397 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt @@ -165,7 +165,7 @@ fun SettingsScreen( title = "Notifications", description = "Manage item and promotional notifications", onClick = { - destinations[Routes.NOTIFICATIONS]?.invoke() + destinations[Routes.NOTIFICATIONS_SETTING]?.invoke() }, trailingIcon = { Icon( From 74a0cb42126e71151c87ba98dc9a5abe0417aa9a Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Wed, 23 Oct 2024 18:25:34 -0400 Subject: [PATCH 003/126] navigation for notification home --- .../ui/navigation/MainTabbedNavigation.kt | 27 ++++++++++++- .../eatery/ui/navigation/NavigationItem.kt | 1 + .../android/eatery/ui/screens/HomeScreen.kt | 40 +++++++++++++++---- .../ui/screens/NotificationsHomeScreen.kt | 29 ++++++++++++++ 4 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt index d6b070d9..5d9dfabe 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt @@ -36,6 +36,7 @@ import com.cornellappdev.android.eatery.ui.screens.FirstTimeShown import com.cornellappdev.android.eatery.ui.screens.HomeScreen import com.cornellappdev.android.eatery.ui.screens.LegalScreen import com.cornellappdev.android.eatery.ui.screens.NearestScreen +import com.cornellappdev.android.eatery.ui.screens.NotificationsHomeScreen import com.cornellappdev.android.eatery.ui.screens.NotificationsSettingsScreen import com.cornellappdev.android.eatery.ui.screens.OnboardingScreen import com.cornellappdev.android.eatery.ui.screens.PrivacyScreen @@ -78,6 +79,10 @@ fun NavigationSetup(hasOnboarded: Boolean) { showBottomBar.value = false } + Routes.NOTIFICATIONS_HOME.route -> { + showBottomBar.value = false + } + Routes.PRIVACY.route -> { showBottomBar.value = false } @@ -207,7 +212,10 @@ fun SetupNavHost( navController.navigate(Routes.NEAREST.route) }, onCompareMenusClick = { selectedEateries -> navController.navigate("comparemenus/${selectedEateries.joinToString(",") { it.toString() }}") - } + }, + onNotificationsClick = { + navController.navigate("notifications_home") + } ) } composable( @@ -306,6 +314,7 @@ fun SetupNavHost( Routes.ABOUT to { navController.navigate(Routes.ABOUT.route) }, Routes.FAVORITES to { navController.navigate(Routes.FAVORITES.route) }, Routes.NOTIFICATIONS_SETTING to { navController.navigate(Routes.NOTIFICATIONS_SETTING.route) }, + Routes.NOTIFICATIONS_HOME to { navController.navigate(Routes.NOTIFICATIONS_HOME.route) }, Routes.LEGAL to { navController.navigate(Routes.LEGAL.route) }, Routes.PRIVACY to { navController.navigate(Routes.PRIVACY.route) }, Routes.SUPPORT to { navController.navigate(Routes.SUPPORT.route) }, @@ -387,6 +396,22 @@ fun SetupNavHost( NotificationsSettingsScreen() } + composable( + route = Routes.NOTIFICATIONS_HOME.route, + enterTransition = { + fadeIn( + initialAlpha = 0f, + animationSpec = tween(durationMillis = 500) + ) + }, + exitTransition = { + fadeOut( + animationSpec = tween(durationMillis = 500) + ) + }) { + NotificationsHomeScreen() + } + composable( route = Routes.NEAREST.route, enterTransition = { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt index 8abf7d4d..0bf14940 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/NavigationItem.kt @@ -86,6 +86,7 @@ enum class Routes(override var route: String) : NavUnit { ABOUT("about"), FAVORITES("favorites"), NOTIFICATIONS_SETTING("notifications_setting"), + NOTIFICATIONS_HOME("notifications_home"), NEAREST("nearest"), LEGAL("legal"), PRIVACY("privacy"), diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 99606383..2a875687 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -10,8 +10,10 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -100,6 +102,7 @@ fun HomeScreen( onFavoriteExpand: () -> Unit, onNearestExpand: () -> Unit, onCompareMenusClick: (selectedEateriesIds: List) -> Unit, + onNotificationsClick : () -> Unit ) { val context = LocalContext.current val favorites = homeViewModel.favoriteEateries.collectAsState().value @@ -249,7 +252,8 @@ fun HomeScreen( }, onResetFilters = { homeViewModel.resetFilters() - } + }, + onNotificationsClick = onNotificationsClick ) } ) @@ -287,6 +291,7 @@ private fun HomeScrollableMainContent( nearestEateries: List, favorites: List, filters: List, + onNotificationsClick: () -> Unit ) { val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() @@ -322,7 +327,8 @@ private fun HomeScrollableMainContent( HomeStickyHeader( collapsed = isFirstVisible.value, loaded = eateriesApiResponse is EateryApiResponse.Success, - onSearchClick = onSearchClick + onSearchClick = onSearchClick, + onNotificationsClick = onNotificationsClick ) } @@ -479,6 +485,7 @@ private fun HomeStickyHeader( collapsed: Boolean, loaded: Boolean, onSearchClick: () -> Unit, + onNotificationsClick: () -> Unit ) { Column( modifier = Modifier @@ -538,11 +545,30 @@ private fun HomeStickyHeader( tint = Color.White ) } - Text( - text = "Eatery", - color = Color.White, - style = EateryBlueTypography.h2 - ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Eatery", + color = Color.White, + style = EateryBlueTypography.h2 + ) + Box( + modifier = Modifier.align(Alignment.CenterVertically) + ){ + Icon( + painter = painterResource(id = R.drawable.ic_bell), + contentDescription = null, + tint = Color.White, + modifier = Modifier.clickable { + onNotificationsClick() + } + ) + } + + } + } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt new file mode 100644 index 00000000..76b443ee --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt @@ -0,0 +1,29 @@ +package com.cornellappdev.android.eatery.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.cornellappdev.android.eatery.ui.theme.EateryBlue +import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography + +@Composable +fun NotificationsHomeScreen( + +){ + Column( + modifier = Modifier + .padding(top = 36.dp, start = 16.dp, end = 16.dp) + .fillMaxSize() + ) { + Text( + text = "Notifications Home TEST", + color = EateryBlue, + style = EateryBlueTypography.h2, + modifier = Modifier.padding(top = 7.dp) + ) + } +} \ No newline at end of file From bc8e152a48656ed7035c37ee43951c00eccba26f Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Wed, 6 Nov 2024 16:24:11 -0500 Subject: [PATCH 004/126] notifications pt1 --- .../notifications/FavoriteItemRow.kt | 108 ++++++++++++++++++ .../ui/screens/NotificationsHomeScreen.kt | 29 ++++- .../ui/screens/NotificationsSettingsScreen.kt | 53 ++++++--- app/src/main/res/drawable/notif_star.xml | 14 +++ 4 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt create mode 100644 app/src/main/res/drawable/notif_star.xml diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt new file mode 100644 index 00000000..2108af07 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt @@ -0,0 +1,108 @@ +package com.cornellappdev.android.eatery.ui.components.notifications + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Star +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.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cornellappdev.android.eatery.R +import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography +import com.cornellappdev.android.eatery.ui.theme.GrayZero + +@Composable +fun FavoriteItemRow( + itemName: String, + atEateries: List +) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.notif_star), + contentDescription = "Notification Star Icon", + tint = Color.Yellow + ) + Column( + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = itemName, + style = EateryBlueTypography.h5, + modifier = Modifier.padding(end = 10.dp) + ) + Text( + text = "today", + fontSize = 10.sp, + style = EateryBlueTypography.body1, + color = Color.LightGray + ) + } + Row { + condenseEateriesName(atEateries) + } + } + IconButton( + onClick = { + // TODO: Add click action here + }, + modifier = Modifier + .size(24.dp) + .background( + color = GrayZero, + shape = CircleShape + ) + ) { + Icon( + Icons.Default.ArrowForward, + contentDescription = "", + tint = Color.Black + ) + } + } +} + +@Composable +private fun condenseEateriesName(atEateries: List) { + val text = if (atEateries.size > 1) { + "${atEateries.last()} + ${atEateries.size - 1} other" + } else { + atEateries.lastOrNull() ?: "" + } + + val suffix = when(atEateries.size){ + 1 -> { + "" + } + 2->{ + "eatery" + } + else-> { + "eateries" + } + } + + Text( + text = "At $text $suffix" + ) +} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt index 76b443ee..5203f7ae 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt @@ -3,10 +3,12 @@ package com.cornellappdev.android.eatery.ui.screens import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.cornellappdev.android.eatery.ui.components.notifications.FavoriteItemRow import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography @@ -16,14 +18,35 @@ fun NotificationsHomeScreen( ){ Column( modifier = Modifier - .padding(top = 36.dp, start = 16.dp, end = 16.dp) + .padding(top = 40.dp, start = 16.dp, end = 16.dp) .fillMaxSize() ) { Text( - text = "Notifications Home TEST", + text = "Notifications", color = EateryBlue, style = EateryBlueTypography.h2, - modifier = Modifier.padding(top = 7.dp) + modifier = Modifier.padding(top = 7.dp, bottom = 20.dp) ) + Text( + text = "Favorite Items", + style = EateryBlueTypography.h4, + modifier = Modifier.padding(bottom = 20.dp) + ) + + LazyColumn { + item{ + FavoriteItemRow( + "Chicken Nuggets", listOf("Rose House") + ) + } + item{ + FavoriteItemRow( + "French Fries", listOf("Bethe House") + ) + } + } + + + } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt index bbccf0be..7561b353 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt @@ -8,6 +8,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -21,9 +25,9 @@ import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.messaging.RemoteMessage.Notification @Composable -fun NotificationsSettingsScreen( +fun NotificationsSettingsScreen() { + var allNotificationsEnabled by remember { mutableStateOf(false) } -){ Column( modifier = Modifier .padding(top = 36.dp, start = 16.dp, end = 16.dp) @@ -42,22 +46,45 @@ fun NotificationsSettingsScreen( color = GraySix, modifier = Modifier.padding(top = 7.dp, bottom = 12.dp) ) + SwitchOption( - title = "Pause all notifications", + title = "All Notifications", description = "", - initialValue = false, - onCheckedChange = { + initialValue = allNotificationsEnabled, + onCheckedChange = { enabled -> + allNotificationsEnabled = enabled } ) - SwitchOption( - title = "Favorite item notifications", - description = "Get notified when favorite items are served", - initialValue = false, - onCheckedChange = { - } - ) + if (allNotificationsEnabled) { + Spacer(modifier = Modifier.height(12.dp)) + + SwitchOption( + title = "Favorite item being served", + description = "Get notified when favorite items are served", + initialValue = false, + onCheckedChange = { + //todo + } + ) + SwitchOption( + title = "Favorite eatery opening", + description = "Get notified when your favorite eatery opens", + initialValue = false, + onCheckedChange = { + //todo + } + ) + SwitchOption( + title = "Favorite eatery closing", + description = "Get notified when your favorite eatery closes", + initialValue = false, + onCheckedChange = { + //todo + } + ) + } } -} +} \ No newline at end of file diff --git a/app/src/main/res/drawable/notif_star.xml b/app/src/main/res/drawable/notif_star.xml new file mode 100644 index 00000000..e81e227a --- /dev/null +++ b/app/src/main/res/drawable/notif_star.xml @@ -0,0 +1,14 @@ + + + From b067889a7fe029abb1002eee38b704c18d3baf1c Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Wed, 6 Nov 2024 17:44:09 -0500 Subject: [PATCH 005/126] add specs and icons --- .../notifications/FavoriteItemRow.kt | 47 ++++++++++++++++--- .../ui/screens/NotificationsHomeScreen.kt | 22 +++++++-- .../main/res/drawable/ic_new_notif_star.xml | 17 +++++++ .../{notif_star.xml => ic_notif_star.xml} | 0 4 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 app/src/main/res/drawable/ic_new_notif_star.xml rename app/src/main/res/drawable/{notif_star.xml => ic_notif_star.xml} (100%) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt index 2108af07..0d0cad68 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt @@ -12,7 +12,6 @@ import androidx.compose.material.IconButton import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForward -import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,7 +26,8 @@ import com.cornellappdev.android.eatery.ui.theme.GrayZero @Composable fun FavoriteItemRow( itemName: String, - atEateries: List + atEateries: List, + newNotif : Boolean ) { Row( modifier = Modifier @@ -36,9 +36,11 @@ fun FavoriteItemRow( verticalAlignment = Alignment.CenterVertically ) { Icon( - painter = painterResource(id = R.drawable.notif_star), + painter = if(newNotif) painterResource(id = R.drawable.ic_new_notif_star) + else painterResource(id = R.drawable.ic_notif_star), contentDescription = "Notification Star Icon", - tint = Color.Yellow + tint = Color.Unspecified, + modifier = Modifier.padding(end=12.dp) ) Column( modifier = Modifier.weight(1f) @@ -82,15 +84,29 @@ fun FavoriteItemRow( } } + +/** + * Displays a formatted Text composable summarizing a list of eatery names based on the number of eateries. + * - If there is only one eatery, it displays the condensed name of that eatery. + * - If there are two eateries, it displays the primary eatery name followed by "and 1 other eatery." + * - If there are three or more eateries, it displays the primary eatery name followed by "and n other + * eateries," where n is the count of additional eateries. + * + * @param eateries A list of eatery names to display. + * @Composable + * @return A Text composable that shows the primary eatery name with a summary of additional eateries, + * if any. + */ @Composable private fun condenseEateriesName(atEateries: List) { + val condensed = atEateries.map { condenseDiningHallNames(it) } val text = if (atEateries.size > 1) { - "${atEateries.last()} + ${atEateries.size - 1} other" + "${condensed.last()} + ${atEateries.size - 1} other" } else { - atEateries.lastOrNull() ?: "" + condensed.lastOrNull() ?: "" } - val suffix = when(atEateries.size){ + val suffix = when(condensed.size){ 1 -> { "" } @@ -106,3 +122,20 @@ private fun condenseEateriesName(atEateries: List) { text = "At $text $suffix" ) } + +/** + * Condenses the specified dining hall name by removing the phrase "Dining Room." + * If the name contains "Dining Room," the function returns the name without this phrase. + * Special Case: If the name is "Jansen's Dining Room at Bethe House," it returns "Bethe House." + * + * @param name The original dining hall name to be condensed. + * @return The condensed dining hall name with "Dining Room" removed, + * or "Bethe House" for the specific "Jansen's Dining Room at Bethe House." + */ +private fun condenseDiningHallNames(name : String) : String{ + if(name.contains("Bethe", ignoreCase = true)) return "Bethe House" + if (name.contains("Dining Room", ignoreCase = true)) { + return name.replace("Dining Room", "", ignoreCase = true).trim() + } + return name +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt index 5203f7ae..43391634 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt @@ -36,17 +36,29 @@ fun NotificationsHomeScreen( LazyColumn { item{ FavoriteItemRow( - "Chicken Nuggets", listOf("Rose House") + "Chicken Nuggets", listOf("Rose House"), true ) } item{ FavoriteItemRow( - "French Fries", listOf("Bethe House") + "French Fries", listOf("Bethe House","Rose House"), false + ) + } + item{ + FavoriteItemRow( + "Sweet Potato Fries", listOf("Bethe House","Rose House","Toni Morrison"), false + ) + } + item{ + FavoriteItemRow( + "Chicken Parm", listOf("Jansen's Dining Room at Bethe House"), false + ) + } + item{ + FavoriteItemRow( + "Burgers", listOf("Rose House Dining Room"), false ) } } - - - } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_new_notif_star.xml b/app/src/main/res/drawable/ic_new_notif_star.xml new file mode 100644 index 00000000..e697d19c --- /dev/null +++ b/app/src/main/res/drawable/ic_new_notif_star.xml @@ -0,0 +1,17 @@ + + + + diff --git a/app/src/main/res/drawable/notif_star.xml b/app/src/main/res/drawable/ic_notif_star.xml similarity index 100% rename from app/src/main/res/drawable/notif_star.xml rename to app/src/main/res/drawable/ic_notif_star.xml From 492743c23827b710c6c89ccbf820082f4965d6b4 Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Thu, 7 Nov 2024 15:33:10 -0500 Subject: [PATCH 006/126] specs --- .../eatery/ui/components/notifications/FavoriteItemRow.kt | 7 ++++--- .../eatery/ui/screens/NotificationsSettingsScreen.kt | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt index 0d0cad68..43411494 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt @@ -88,9 +88,10 @@ fun FavoriteItemRow( /** * Displays a formatted Text composable summarizing a list of eatery names based on the number of eateries. * - If there is only one eatery, it displays the condensed name of that eatery. - * - If there are two eateries, it displays the primary eatery name followed by "and 1 other eatery." - * - If there are three or more eateries, it displays the primary eatery name followed by "and n other - * eateries," where n is the count of additional eateries. + * - If there are two eateries, it displays the condensed eatery name of the last eatery + * followed by "and 1 other eatery." + * - If there are three or more eateries, it displays the condensed eatery name of the last eatery + * followed by "and n other eateries," where n is the count of additional eateries. * * @param eateries A list of eatery names to display. * @Composable diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt index 7561b353..a76b5fb7 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt @@ -62,7 +62,7 @@ fun NotificationsSettingsScreen() { SwitchOption( title = "Favorite item being served", description = "Get notified when favorite items are served", - initialValue = false, + initialValue = true, onCheckedChange = { //todo } @@ -71,7 +71,7 @@ fun NotificationsSettingsScreen() { SwitchOption( title = "Favorite eatery opening", description = "Get notified when your favorite eatery opens", - initialValue = false, + initialValue = true, onCheckedChange = { //todo } @@ -80,7 +80,7 @@ fun NotificationsSettingsScreen() { SwitchOption( title = "Favorite eatery closing", description = "Get notified when your favorite eatery closes", - initialValue = false, + initialValue = true, onCheckedChange = { //todo } From 39127fe23c7e0c99d12b351bc9ecc22daca24cfd Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Sun, 10 Nov 2024 12:44:11 -0500 Subject: [PATCH 007/126] reformat code --- .../notifications/FavoriteItemRow.kt | 18 ++++++++++-------- .../android/eatery/ui/screens/HomeScreen.kt | 4 ++-- .../ui/screens/NotificationsHomeScreen.kt | 18 ++++++++++-------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt index 43411494..8136a49c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt @@ -27,7 +27,7 @@ import com.cornellappdev.android.eatery.ui.theme.GrayZero fun FavoriteItemRow( itemName: String, atEateries: List, - newNotif : Boolean + newNotif: Boolean ) { Row( modifier = Modifier @@ -36,11 +36,11 @@ fun FavoriteItemRow( verticalAlignment = Alignment.CenterVertically ) { Icon( - painter = if(newNotif) painterResource(id = R.drawable.ic_new_notif_star) + painter = if (newNotif) painterResource(id = R.drawable.ic_new_notif_star) else painterResource(id = R.drawable.ic_notif_star), contentDescription = "Notification Star Icon", tint = Color.Unspecified, - modifier = Modifier.padding(end=12.dp) + modifier = Modifier.padding(end = 12.dp) ) Column( modifier = Modifier.weight(1f) @@ -107,14 +107,16 @@ private fun condenseEateriesName(atEateries: List) { condensed.lastOrNull() ?: "" } - val suffix = when(condensed.size){ + val suffix = when (condensed.size) { 1 -> { "" } - 2->{ + + 2 -> { "eatery" } - else-> { + + else -> { "eateries" } } @@ -133,8 +135,8 @@ private fun condenseEateriesName(atEateries: List) { * @return The condensed dining hall name with "Dining Room" removed, * or "Bethe House" for the specific "Jansen's Dining Room at Bethe House." */ -private fun condenseDiningHallNames(name : String) : String{ - if(name.contains("Bethe", ignoreCase = true)) return "Bethe House" +private fun condenseDiningHallNames(name: String): String { + if (name.contains("Bethe", ignoreCase = true)) return "Bethe House" if (name.contains("Dining Room", ignoreCase = true)) { return name.replace("Dining Room", "", ignoreCase = true).trim() } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 5b7f2d1f..491d6272 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -102,7 +102,7 @@ fun HomeScreen( onFavoriteExpand: () -> Unit, onNearestExpand: () -> Unit, onCompareMenusClick: (selectedEateriesIds: List) -> Unit, - onNotificationsClick : () -> Unit + onNotificationsClick: () -> Unit ) { val context = LocalContext.current val favorites = homeViewModel.favoriteEateries.collectAsState().value @@ -532,7 +532,7 @@ private fun HomeStickyHeader( ) Box( modifier = Modifier.align(Alignment.CenterVertically) - ){ + ) { Icon( painter = painterResource(id = R.drawable.ic_bell), contentDescription = null, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt index 43391634..66a85e1f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt @@ -15,7 +15,7 @@ import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography @Composable fun NotificationsHomeScreen( -){ +) { Column( modifier = Modifier .padding(top = 40.dp, start = 16.dp, end = 16.dp) @@ -34,27 +34,29 @@ fun NotificationsHomeScreen( ) LazyColumn { - item{ + item { FavoriteItemRow( "Chicken Nuggets", listOf("Rose House"), true ) } - item{ + item { FavoriteItemRow( - "French Fries", listOf("Bethe House","Rose House"), false + "French Fries", listOf("Bethe House", "Rose House"), false ) } - item{ + item { FavoriteItemRow( - "Sweet Potato Fries", listOf("Bethe House","Rose House","Toni Morrison"), false + "Sweet Potato Fries", + listOf("Bethe House", "Rose House", "Toni Morrison"), + false ) } - item{ + item { FavoriteItemRow( "Chicken Parm", listOf("Jansen's Dining Room at Bethe House"), false ) } - item{ + item { FavoriteItemRow( "Burgers", listOf("Rose House Dining Room"), false ) From fa0f97f795f4aadddb9440c6fdd6f6d5b3354a19 Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Sun, 10 Nov 2024 13:52:20 -0500 Subject: [PATCH 008/126] fixes --- .../notifications/FavoriteItemRow.kt | 21 +++++++++++++---- .../android/eatery/ui/screens/HomeScreen.kt | 23 ++++++++----------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt index 8136a49c..ca602241 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.cornellappdev.android.eatery.R @@ -26,7 +27,7 @@ import com.cornellappdev.android.eatery.ui.theme.GrayZero @Composable fun FavoriteItemRow( itemName: String, - atEateries: List, + eateries: List, newNotif: Boolean ) { Row( @@ -54,14 +55,14 @@ fun FavoriteItemRow( modifier = Modifier.padding(end = 10.dp) ) Text( - text = "today", + text = "Today", fontSize = 10.sp, style = EateryBlueTypography.body1, - color = Color.LightGray + color = Color.DarkGray ) } Row { - condenseEateriesName(atEateries) + condenseEateriesName(eateries) } } IconButton( @@ -122,7 +123,17 @@ private fun condenseEateriesName(atEateries: List) { } Text( - text = "At $text $suffix" + text = "At ", + fontSize = 12.sp + ) + Text( + text = text, + fontWeight = FontWeight(600), + fontSize = 12.sp + ) + Text( + text = " $suffix", + fontSize = 12.sp ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 491d6272..4a021a08 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -523,25 +523,22 @@ private fun HomeStickyHeader( } Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { Text( text = "Eatery", color = Color.White, style = EateryBlueTypography.h2 ) - Box( - modifier = Modifier.align(Alignment.CenterVertically) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_bell), - contentDescription = null, - tint = Color.White, - modifier = Modifier.clickable { - onNotificationsClick() - } - ) - } + Icon( + painter = painterResource(id = R.drawable.ic_bell), + contentDescription = null, + tint = Color.White, + modifier = Modifier.clickable { + onNotificationsClick() + } + ) } From 1c151930be030a8af4c2908b901044ee596033be Mon Sep 17 00:00:00 2001 From: Olivia Jiang Date: Wed, 13 Nov 2024 17:06:08 -0500 Subject: [PATCH 009/126] fixes - adding padding to settings switch option - capitalized composable name - added todo for viewmodel in NotificationsHomeScreen --- .../notifications/FavoriteItemRow.kt | 4 +- .../ui/components/settings/SwitchOption.kt | 40 +++++++++++-------- .../ui/screens/NotificationsHomeScreen.kt | 2 +- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt index ca602241..a1bcb8f0 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt @@ -62,7 +62,7 @@ fun FavoriteItemRow( ) } Row { - condenseEateriesName(eateries) + CondenseEateriesName(eateries) } } IconButton( @@ -100,7 +100,7 @@ fun FavoriteItemRow( * if any. */ @Composable -private fun condenseEateriesName(atEateries: List) { +private fun CondenseEateriesName(atEateries: List) { val condensed = atEateries.map { condenseDiningHallNames(it) } val text = if (atEateries.size > 1) { "${condensed.last()} + ${atEateries.size - 1} other" diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SwitchOption.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SwitchOption.kt index 3407f770..24f66f2c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SwitchOption.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SwitchOption.kt @@ -1,5 +1,7 @@ package com.cornellappdev.android.eatery.ui.components.settings +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.runtime.Composable @@ -10,6 +12,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.GrayOne @@ -22,22 +25,25 @@ fun SwitchOption( initialValue: Boolean = true ) { var switched by remember { mutableStateOf(initialValue) } - SettingsOption(title = title, description = description, onClick = { }, - trailingIcon = { - Switch( - modifier = Modifier.scale(2f), - checked = switched, - onCheckedChange = { - switched = !switched - onCheckedChange(switched) - }, - enabled = enabled, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - uncheckedThumbColor = Color.White, - checkedTrackColor = EateryBlue, - uncheckedTrackColor = GrayOne + Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp)){ + SettingsOption(title = title, description = description, onClick = { }, + trailingIcon = { + Switch( + modifier = Modifier.scale(2f), + checked = switched, + onCheckedChange = { + switched = !switched + onCheckedChange(switched) + }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + checkedTrackColor = EateryBlue, + uncheckedTrackColor = GrayOne + ) ) - ) - }) + }) + } + } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt index 66a85e1f..2a42e538 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsHomeScreen.kt @@ -14,7 +14,7 @@ import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography @Composable fun NotificationsHomeScreen( - +//TODO added hilt view model injection here ) { Column( modifier = Modifier From 26312c914199b6a37d6701391470299378c0c8a2 Mon Sep 17 00:00:00 2001 From: zachseidner1 Date: Mon, 24 Mar 2025 22:19:10 -0400 Subject: [PATCH 010/126] Address my own comments --- .../notifications/FavoriteItemRow.kt | 21 +++--- .../ui/components/settings/SettingsOption.kt | 2 +- .../ui/components/settings/SwitchOption.kt | 22 +++--- .../ui/screens/NotificationsSettingsScreen.kt | 68 ++++++++----------- 4 files changed, 55 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt index a1bcb8f0..afd35539 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/notifications/FavoriteItemRow.kt @@ -3,7 +3,6 @@ package com.cornellappdev.android.eatery.ui.components.notifications import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -28,11 +27,12 @@ import com.cornellappdev.android.eatery.ui.theme.GrayZero fun FavoriteItemRow( itemName: String, eateries: List, - newNotif: Boolean + newNotif: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, ) { Row( - modifier = Modifier - .fillMaxSize() + modifier = modifier .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -66,9 +66,7 @@ fun FavoriteItemRow( } } IconButton( - onClick = { - // TODO: Add click action here - }, + onClick = onClick, modifier = Modifier .size(24.dp) .background( @@ -88,6 +86,7 @@ fun FavoriteItemRow( /** * Displays a formatted Text composable summarizing a list of eatery names based on the number of eateries. + * Also prepends "At" to the eateries list. * - If there is only one eatery, it displays the condensed name of that eatery. * - If there are two eateries, it displays the condensed eatery name of the last eatery * followed by "and 1 other eatery." @@ -100,10 +99,10 @@ fun FavoriteItemRow( * if any. */ @Composable -private fun CondenseEateriesName(atEateries: List) { - val condensed = atEateries.map { condenseDiningHallNames(it) } - val text = if (atEateries.size > 1) { - "${condensed.last()} + ${atEateries.size - 1} other" +private fun CondenseEateriesName(eateries: List) { + val condensed = eateries.map { condenseDiningHallNames(it) } + val text = if (eateries.size > 1) { + "${condensed.last()} + ${eateries.size - 1} other" } else { condensed.lastOrNull() ?: "" } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SettingsOption.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SettingsOption.kt index 575b9c86..2e269487 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SettingsOption.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SettingsOption.kt @@ -28,7 +28,7 @@ import com.cornellappdev.android.eatery.ui.theme.GrayOne @Composable fun SettingsOption( title: String, - onClick: () -> Unit, + onClick: () -> Unit = {}, description: String? = null, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SwitchOption.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SwitchOption.kt index 24f66f2c..68162cb0 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SwitchOption.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/settings/SwitchOption.kt @@ -1,16 +1,17 @@ package com.cornellappdev.android.eatery.ui.components.settings import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material.Switch -import androidx.compose.material.SwitchDefaults +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.cornellappdev.android.eatery.ui.theme.EateryBlue @@ -25,11 +26,14 @@ fun SwitchOption( initialValue: Boolean = true ) { var switched by remember { mutableStateOf(initialValue) } - Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp)){ - SettingsOption(title = title, description = description, onClick = { }, + Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp)) { + SettingsOption( + title = title, description = description, onClick = { }, trailingIcon = { Switch( - modifier = Modifier.scale(2f), + modifier = Modifier + .width(51.dp) + .height(31.dp), checked = switched, onCheckedChange = { switched = !switched @@ -40,8 +44,10 @@ fun SwitchOption( checkedThumbColor = Color.White, uncheckedThumbColor = Color.White, checkedTrackColor = EateryBlue, - uncheckedTrackColor = GrayOne - ) + uncheckedTrackColor = GrayOne, + checkedBorderColor = Color.Transparent, + uncheckedBorderColor = Color.Transparent, + ), ) }) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt index a76b5fb7..cdd4745a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NotificationsSettingsScreen.kt @@ -1,6 +1,5 @@ package com.cornellappdev.android.eatery.ui.screens -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -8,10 +7,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -21,12 +16,9 @@ import com.cornellappdev.android.eatery.ui.components.settings.SwitchOption import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.theme.GraySix -import com.google.firebase.analytics.FirebaseAnalytics -import com.google.firebase.messaging.RemoteMessage.Notification @Composable fun NotificationsSettingsScreen() { - var allNotificationsEnabled by remember { mutableStateOf(false) } Column( modifier = Modifier @@ -50,41 +42,41 @@ fun NotificationsSettingsScreen() { SwitchOption( title = "All Notifications", description = "", - initialValue = allNotificationsEnabled, - onCheckedChange = { enabled -> - allNotificationsEnabled = enabled + initialValue = true, + onCheckedChange = { +// TODO() } ) - if (allNotificationsEnabled) { - Spacer(modifier = Modifier.height(12.dp)) + // TODO: Conditional visibility based on whether all notifications are enabled - SwitchOption( - title = "Favorite item being served", - description = "Get notified when favorite items are served", - initialValue = true, - onCheckedChange = { - //todo - } - ) + Spacer(modifier = Modifier.height(12.dp)) - SwitchOption( - title = "Favorite eatery opening", - description = "Get notified when your favorite eatery opens", - initialValue = true, - onCheckedChange = { - //todo - } - ) + SwitchOption( + title = "Favorite item being served", + description = "Get notified when favorite items are served", + initialValue = true, + onCheckedChange = { +// TODO() + } + ) + + SwitchOption( + title = "Favorite eatery opening", + description = "Get notified when your favorite eatery opens", + initialValue = true, + onCheckedChange = { +// TODO() + } + ) - SwitchOption( - title = "Favorite eatery closing", - description = "Get notified when your favorite eatery closes", - initialValue = true, - onCheckedChange = { - //todo - } - ) - } + SwitchOption( + title = "Favorite eatery closing", + description = "Get notified when your favorite eatery closes", + initialValue = true, + onCheckedChange = { +// TODO() + } + ) } } \ No newline at end of file From 201246da7098dc87d47c09c427958537276eb4e3 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 6 Sep 2025 14:46:54 -0400 Subject: [PATCH 011/126] Make login work by making WebView visible and increasing attempt threshold. --- .../eatery/ui/components/login/LoginPage.kt | 117 +++++------------- 1 file changed, 28 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index cbc0b957..57903807 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -1,14 +1,13 @@ package com.cornellappdev.android.eatery.ui.components.login import android.content.Context -import android.os.Handler -import android.util.Log import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -30,7 +29,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import com.cornellappdev.android.eatery.BuildConfig -import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.ui.components.general.CustomTextField import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography @@ -137,7 +135,6 @@ fun LoginPage( ) } if (loginState.loading) { - // LoginWebView should take up no space whatsoever. User should not be able to see it. LoginWebView( netId = loginState.netid, password = loginState.password, @@ -155,7 +152,7 @@ fun LoginPage( } @Composable -fun LoginWebView( +private fun LoginWebView( netId: String, password: String, onSuccess: (String) -> Unit, @@ -165,32 +162,32 @@ fun LoginWebView( CookieManager.getInstance().removeAllCookies(null) CookieManager.getInstance().flush() - AndroidView(factory = { - WebView(it).apply { - visibility = View.INVISIBLE - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - settings.javaScriptEnabled = true - webViewClient = - CustomWebViewClient( - netId = netId, - password = password, - onSuccess = onSuccess, - onWrongCredentials = onWrongCredentials, - context = context + AndroidView( + factory = { + WebView(it).apply { + visibility = View.VISIBLE + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT ) - loadUrl(BuildConfig.SESSIONID_WEBVIEW_URL) - } - }, update = { - it.loadUrl(BuildConfig.SESSIONID_WEBVIEW_URL) - }) + settings.javaScriptEnabled = true + webViewClient = + CustomWebViewClient( + netId = netId, + password = password, + onSuccess = onSuccess, + onWrongCredentials = onWrongCredentials, + context = context + ) + loadUrl(BuildConfig.SESSIONID_WEBVIEW_URL) + } + }, + update = { + it.loadUrl(BuildConfig.SESSIONID_WEBVIEW_URL) + }) } -var loadingMessage = "" - -class CustomWebViewClient( +private class CustomWebViewClient( private val netId: String, private val password: String, val onSuccess: (String) -> Unit, @@ -205,68 +202,10 @@ class CustomWebViewClient( lastUrl = url if (url?.contains("sessionId=") == true) { val sessionToken = url.substringAfter("sessionId=").removeSuffix("&") - Log.d("SessionID", sessionToken) onSuccess(sessionToken) - } else if (attempts > 1) { + } else if (attempts > 2) { + // We allow 2 attempts because there is an intermediate redirect that happens. onWrongCredentials() - } else if (url?.contains("auth/prompt") == true) { - val handler = Handler() - val interval = 1000L // Interval in milliseconds (e.g., check every second) - val runnable = object : Runnable { - override fun run() { - // Execute JavaScript to get document element by ID - view?.evaluateJavascript("javascript:document.querySelectorAll('[id*=\"header-text\"], [id*=\"push-success-label\"]')[0].innerHTML;") { message -> - loadingMessage = message - when (loadingMessage) { - "\"Check for a Duo Push\"" -> { - LoginToast( - context, - "Authenticating", - R.drawable.ic_bell, - R.color.light_yellow, - R.color.yellow - ) - } - - "\"Open Duo Mobile\"" -> { - LoginToast( - context, - "Authenticating", - R.drawable.ic_bell, - R.color.light_yellow, - R.color.yellow - ) - } - - "\"Duo Push timed out\"" -> { - LoginToast( - context, - "Timed Out", - R.drawable.ic_error, - R.color.light_red, - R.color.red - ) - } - - "\"Success!\"" -> { - LoginToast( - context, - "Successful Login", - es.dmoral.toasty.R.drawable.ic_check_white_24dp, - R.color.light_green, - R.color.green - ) - } - } - } - - // Schedule the next execution - handler.postDelayed(this, interval) - } - } - - // Start the initial execution - handler.post(runnable) } else if (url?.contains("shibidp") == true) { // Injects the username and password into their respective spots on the // login form. @@ -275,7 +214,7 @@ class CustomWebViewClient( document.getElementsByName('j_password')[0].value = '${password}'; document.getElementsByName('_eventId_proceed')[0].click(); """.trimIndent() - view?.evaluateJavascript(script) { attempts += 1 } + view?.evaluateJavascript(script) { attempts++ } } super.onPageFinished(view, url) } From 863e7b3120a1d067309fd8210faec757cefb4d4b Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 6 Sep 2025 14:59:18 -0400 Subject: [PATCH 012/126] Add LoginPagePreview --- .../eatery/ui/components/login/LoginPage.kt | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 57903807..59d1a40f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -7,7 +7,6 @@ import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -24,6 +23,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextStyle 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.compose.ui.viewinterop.AndroidView @@ -36,15 +36,36 @@ import com.cornellappdev.android.eatery.ui.theme.GraySix import com.cornellappdev.android.eatery.ui.theme.GrayThree import com.cornellappdev.android.eatery.ui.theme.GrayZero import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel +import com.cornellappdev.android.eatery.util.EateryPreview import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer +import kotlinx.coroutines.Job @Composable fun LoginPage( loginState: LoginViewModel.State.Login, loginViewModel: LoginViewModel, onWrongCredentials: () -> Unit = {} +) { + LoginPageContent( + loginState = loginState, + onWrongCredentials = onWrongCredentials, + onNetIdTyped = loginViewModel::onNetIDTyped, + onPasswordTyped = loginViewModel::onPasswordTyped, + onLoginPressed = loginViewModel::onLoginPressed, + getUser = loginViewModel::getUser + ) +} + +@Composable +private fun LoginPageContent( + loginState: LoginViewModel.State.Login, + onWrongCredentials: () -> Unit, + onNetIdTyped: (String) -> Unit, + onPasswordTyped: (String) -> Unit, + onLoginPressed: () -> Unit, + getUser: ((String) -> Job)? ) { val shimmer = rememberShimmer(ShimmerBounds.View) val shimmerModifier = @@ -54,7 +75,6 @@ fun LoginPage( val clickable = loginState.netid.isNotEmpty() && loginState.password.isNotEmpty() && !loginState.loading - Column( modifier = Modifier .zIndex(1f) @@ -76,9 +96,7 @@ fun LoginPage( CustomTextField( modifier = Modifier.then(shimmerModifier), value = loginState.netid, - onValueChange = { - loginViewModel.onNetIDTyped(it) - }, + onValueChange = onNetIdTyped, enabled = !loginState.loading, placeholder = "Type your NetID (e.g. abc123)", backgroundColor = GrayZero, @@ -97,9 +115,7 @@ fun LoginPage( CustomTextField( modifier = Modifier.then(shimmerModifier), value = loginState.password, - onValueChange = { - loginViewModel.onPasswordTyped(it) - }, + onValueChange = onPasswordTyped, enabled = !loginState.loading, placeholder = "Type your password...", backgroundColor = GrayZero, @@ -121,7 +137,7 @@ fun LoginPage( .height(56.dp), onClick = { focusManager.clearFocus() - loginViewModel.onLoginPressed() + onLoginPressed() }, colors = ButtonDefaults.buttonColors( backgroundColor = if (clickable) EateryBlue else GrayZero @@ -139,25 +155,42 @@ fun LoginPage( netId = loginState.netid, password = loginState.password, onSuccess = { sessionId -> - loginViewModel.getUser(sessionId) - loginViewModel.onLoginPressed() + getUser?.invoke(sessionId) + onLoginPressed() }, onWrongCredentials = { onWrongCredentials() - }, - context = LocalContext.current + } ) } } } + +@Preview +@Composable +private fun LoginPagePreview() = EateryPreview { + LoginPageContent( + loginState = LoginViewModel.State.Login( + netid = "aaa00", + password = "myVeryLongPassword", + failureMessage = null, + loading = false + ), + onWrongCredentials = {}, + onNetIdTyped = {}, + onPasswordTyped = {}, + onLoginPressed = {}, + getUser = null + ) +} + @Composable private fun LoginWebView( netId: String, password: String, onSuccess: (String) -> Unit, onWrongCredentials: () -> Unit, - context: Context ) { CookieManager.getInstance().removeAllCookies(null) CookieManager.getInstance().flush() @@ -177,7 +210,6 @@ private fun LoginWebView( password = password, onSuccess = onSuccess, onWrongCredentials = onWrongCredentials, - context = context ) loadUrl(BuildConfig.SESSIONID_WEBVIEW_URL) } @@ -192,7 +224,6 @@ private class CustomWebViewClient( private val password: String, val onSuccess: (String) -> Unit, val onWrongCredentials: () -> Unit, - val context: Context ) : WebViewClient() { private var attempts = 0 private var lastUrl: String? = null From 61898dc7a2af9a2963181816afc6846539e337c8 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 6 Sep 2025 15:17:32 -0400 Subject: [PATCH 013/126] Add ProfileScreenPreview --- .../eatery/ui/components/login/LoginPage.kt | 4 +- .../eatery/ui/screens/ProfileScreen.kt | 88 ++++++++++++++----- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 59d1a40f..3ca57630 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -1,6 +1,5 @@ package com.cornellappdev.android.eatery.ui.components.login -import android.content.Context import android.view.View import android.view.ViewGroup import android.webkit.CookieManager @@ -19,7 +18,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -59,7 +57,7 @@ fun LoginPage( } @Composable -private fun LoginPageContent( +fun LoginPageContent( loginState: LoginViewModel.State.Login, onWrongCredentials: () -> Unit, onNetIdTyped: (String) -> Unit, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 3c94ea44..f94f08a1 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -18,14 +18,17 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage +import com.cornellappdev.android.eatery.ui.components.login.LoginPageContent import com.cornellappdev.android.eatery.ui.components.login.LoginToast import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel +import com.cornellappdev.android.eatery.util.EateryPreview @OptIn(ExperimentalAnimationApi::class) @@ -35,6 +38,42 @@ fun ProfileScreen( onSettingsClicked: () -> Unit, ) { val state = loginViewModel.state.collectAsState().value + ProfileScreenContent( + state, + loginPage = @Composable { + val context = LocalContext.current + LoginPage( + loginState = state as LoginViewModel.State.Login, + loginViewModel = loginViewModel, + onWrongCredentials = { + LoginToast( + context, + "NetID and/or password incorrect", + R.drawable.ic_error, + R.color.light_red, + R.color.red + ) + loginViewModel.onLoginFailed() + } + ) + }, + accountPage = @Composable { + AccountPage( + accountState = state as LoginViewModel.State.Account, + loginViewModel = loginViewModel, + onSettingsClicked = { onSettingsClicked() }) + }, + onSettingsClicked + ) +} + +@Composable +private fun ProfileScreenContent( + state: LoginViewModel.State, + loginPage: @Composable () -> Unit, + accountPage: @Composable () -> Unit, + onSettingsClicked: () -> Unit +) { Column(modifier = Modifier.background(Color.White)) { when (state) { is LoginViewModel.State.Login -> { @@ -72,31 +111,38 @@ fun ProfileScreen( ) } } - val context = LocalContext.current - LoginPage( - loginState = state, - loginViewModel = loginViewModel, - onWrongCredentials = { - LoginToast( - context, - "NetID and/or password incorrect", - R.drawable.ic_error, - R.color.light_red, - R.color.red - ) - loginViewModel.onLoginFailed() - } - ) - + loginPage() } is LoginViewModel.State.Account -> { - AccountPage( - accountState = state, - loginViewModel = loginViewModel, - onSettingsClicked = { onSettingsClicked() }) + accountPage() } } - } } + +@Preview +@Composable +private fun ProfileScreenPreview() = EateryPreview { + val state = LoginViewModel.State.Login( + netid = "aaa00", + password = "myVeryLongPassword", + failureMessage = null, + loading = false + ) + ProfileScreenContent( + state = state, + loginPage = { + LoginPageContent( + loginState = state, + onWrongCredentials = {}, + onNetIdTyped = {}, + onPasswordTyped = {}, + onLoginPressed = {}, + getUser = null + ) + }, + accountPage = { }, + onSettingsClicked = { } + ) +} \ No newline at end of file From e74459d00d99853e9076e311dcf71ce2bda31305 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 6 Sep 2025 18:43:58 -0400 Subject: [PATCH 014/126] Update LoginPage UI and login flow. --- .../eatery/ui/components/login/LoginPage.kt | 202 +++++++----------- .../ui/navigation/MainTabbedNavigation.kt | 55 +++-- .../eatery/ui/screens/ProfileScreen.kt | 102 ++++----- .../eatery/ui/viewmodels/LoginViewModel.kt | 58 +---- 4 files changed, 163 insertions(+), 254 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 3ca57630..55f37765 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -1,11 +1,15 @@ package com.cornellappdev.android.eatery.ui.components.login +import android.annotation.SuppressLint import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -14,11 +18,13 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -27,7 +33,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import com.cornellappdev.android.eatery.BuildConfig -import com.cornellappdev.android.eatery.ui.components.general.CustomTextField +import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.theme.GraySix @@ -44,97 +50,72 @@ import kotlinx.coroutines.Job fun LoginPage( loginState: LoginViewModel.State.Login, loginViewModel: LoginViewModel, - onWrongCredentials: () -> Unit = {} + webViewEnabled: Boolean ) { LoginPageContent( loginState = loginState, - onWrongCredentials = onWrongCredentials, - onNetIdTyped = loginViewModel::onNetIDTyped, - onPasswordTyped = loginViewModel::onPasswordTyped, onLoginPressed = loginViewModel::onLoginPressed, - getUser = loginViewModel::getUser + getUser = loginViewModel::getUser, + webViewEnabled = webViewEnabled ) } @Composable fun LoginPageContent( loginState: LoginViewModel.State.Login, - onWrongCredentials: () -> Unit, - onNetIdTyped: (String) -> Unit, - onPasswordTyped: (String) -> Unit, onLoginPressed: () -> Unit, - getUser: ((String) -> Job)? + getUser: ((String) -> Job)?, + webViewEnabled: Boolean ) { val shimmer = rememberShimmer(ShimmerBounds.View) val shimmerModifier = if (loginState.loading) Modifier.shimmer(customShimmer = shimmer) else Modifier - val passwordFocus = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - val clickable = - loginState.netid.isNotEmpty() && loginState.password.isNotEmpty() && !loginState.loading - + val clickable = !loginState.loading + val loggedIn = remember { mutableStateOf(false) } Column( - modifier = Modifier - .zIndex(1f) - .padding(horizontal = 16.dp) + modifier = Modifier.zIndex(1f) ) { + if (loginState.loading && !loggedIn.value && webViewEnabled) { + LoginWebView( + onLoggedIn = { + loggedIn.value = true + }, + onSuccess = { sessionId -> + getUser?.invoke(sessionId) + onLoginPressed() + } + ) + return + } Text( - text = "See your meal swipes, BRBs, and more", + text = "Log in with your Cornell NetID to see your account balance and history", style = TextStyle(fontWeight = FontWeight.Medium, fontSize = 18.sp), color = GraySix, modifier = Modifier.padding(top = 7.dp) ) - Text( - text = "NetID", - style = EateryBlueTypography.h5, - color = Color.Black, - modifier = Modifier.padding(top = 24.dp, bottom = 12.dp) - ) - - CustomTextField( - modifier = Modifier.then(shimmerModifier), - value = loginState.netid, - onValueChange = onNetIdTyped, - enabled = !loginState.loading, - placeholder = "Type your NetID (e.g. abc123)", - backgroundColor = GrayZero, - onSubmit = { - if (loginState.netid.isNotEmpty()) passwordFocus.requestFocus() - } - ) - - Text( - text = "Password", - style = EateryBlueTypography.h5, - color = Color.Black, - modifier = Modifier.padding(top = 24.dp, bottom = 12.dp) - ) - CustomTextField( - modifier = Modifier.then(shimmerModifier), - value = loginState.password, - onValueChange = onPasswordTyped, - enabled = !loginState.loading, - placeholder = "Type your password...", - backgroundColor = GrayZero, - focusRequester = passwordFocus, - isPassword = true, - onSubmit = { - if (loginState.password.isNotEmpty()) { - focusManager.clearFocus() - } - } - ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_eaterylogo), + contentDescription = "Eatery logo", + modifier = Modifier + .fillMaxWidth(0.5f) + .fillMaxHeight(), + colorFilter = ColorFilter.tint(Color(0xFFB7D3F3)) + ) + } Button( enabled = clickable, shape = RoundedCornerShape(24.dp), modifier = Modifier .fillMaxWidth() .then(shimmerModifier) - .padding(top = 66.dp) .height(56.dp), onClick = { - focusManager.clearFocus() onLoginPressed() }, colors = ButtonDefaults.buttonColors( @@ -148,51 +129,22 @@ fun LoginPageContent( style = EateryBlueTypography.h5 ) } - if (loginState.loading) { - LoginWebView( - netId = loginState.netid, - password = loginState.password, - onSuccess = { sessionId -> - getUser?.invoke(sessionId) - onLoginPressed() - }, - onWrongCredentials = { - onWrongCredentials() - } - ) - } } } - -@Preview -@Composable -private fun LoginPagePreview() = EateryPreview { - LoginPageContent( - loginState = LoginViewModel.State.Login( - netid = "aaa00", - password = "myVeryLongPassword", - failureMessage = null, - loading = false - ), - onWrongCredentials = {}, - onNetIdTyped = {}, - onPasswordTyped = {}, - onLoginPressed = {}, - getUser = null - ) -} - +/** + * Web view that handles NetID login. [onLoggedIn] is called when the user has logged in. + * [onSuccess] is called after [onLoggedIn] when we have grabbed the sessionID from the + * validation page after log in. + */ +@SuppressLint("SetJavaScriptEnabled") @Composable private fun LoginWebView( - netId: String, - password: String, + onLoggedIn: () -> Unit, onSuccess: (String) -> Unit, - onWrongCredentials: () -> Unit, ) { CookieManager.getInstance().removeAllCookies(null) CookieManager.getInstance().flush() - AndroidView( factory = { WebView(it).apply { @@ -201,50 +153,48 @@ private fun LoginWebView( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) + // Necessary for displaying normal login webpage behavior. settings.javaScriptEnabled = true - webViewClient = - CustomWebViewClient( - netId = netId, - password = password, - onSuccess = onSuccess, - onWrongCredentials = onWrongCredentials, - ) + webViewClient = CustomWebViewClient(onSuccess, onLoggedIn) loadUrl(BuildConfig.SESSIONID_WEBVIEW_URL) } }, - update = { - it.loadUrl(BuildConfig.SESSIONID_WEBVIEW_URL) - }) + ) } private class CustomWebViewClient( - private val netId: String, - private val password: String, val onSuccess: (String) -> Unit, - val onWrongCredentials: () -> Unit, + val onLoggedIn: () -> Unit ) : WebViewClient() { - private var attempts = 0 private var lastUrl: String? = null - override fun onPageFinished(view: WebView?, url: String?) { if (lastUrl == url) return lastUrl = url if (url?.contains("sessionId=") == true) { val sessionToken = url.substringAfter("sessionId=").removeSuffix("&") onSuccess(sessionToken) - } else if (attempts > 2) { - // We allow 2 attempts because there is an intermediate redirect that happens. - onWrongCredentials() - } else if (url?.contains("shibidp") == true) { - // Injects the username and password into their respective spots on the - // login form. - val script = """ - document.getElementsByName('j_username')[0].value = '${netId}'; - document.getElementsByName('j_password')[0].value = '${password}'; - document.getElementsByName('_eventId_proceed')[0].click(); - """.trimIndent() - view?.evaluateJavascript(script) { attempts++ } + } else if (url?.contains("eventId_proceed") == true) { + // page after successful login, before validation page. + // hides webview before validation page + onLoggedIn() } super.onPageFinished(view, url) } } + + +@Preview +@Composable +private fun LoginPagePreview() = EateryPreview { + LoginPageContent( + loginState = LoginViewModel.State.Login( + netid = "aaa00", + password = "myVeryLongPassword", + failureMessage = null, + loading = false + ), + onLoginPressed = {}, + getUser = null, + webViewEnabled = false + ) +} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt index 8022b792..a9098138 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -166,6 +167,10 @@ fun SetupNavHost( loginViewModel: LoginViewModel = hiltViewModel(), ) { AppStoreRatingPopup(navigateToSupport = { navController.navigate(Routes.SUPPORT.route) }) + + // Need to handle here so the webview is destroyed before navigating away from profile. + val webViewEnabled = remember { mutableStateOf(true) } + // The starting destination switches to onboarding if it isn't completed. AnimatedNavHost( modifier = modifier, @@ -202,17 +207,18 @@ fun SetupNavHost( animationSpec = tween(durationMillis = 500) ) }) { - HomeScreen(showBottomBar = showBottomBar, onSearchClick = { - FirstTimeShown.firstTimeShown = false - navController.navigate(Routes.SEARCH.route) - }, onEateryClick = { - FirstTimeShown.firstTimeShown = false - navController.navigate("${Routes.EATERY_DETAIL.route}/${it.id}") - }, onFavoriteExpand = { - navController.navigate(Routes.FAVORITES.route) - }, onCompareMenusClick = { selectedEateries -> - navController.navigate("comparemenus/${selectedEateries.joinToString(",") { it.toString() }}") - }, + HomeScreen( + showBottomBar = showBottomBar, onSearchClick = { + FirstTimeShown.firstTimeShown = false + navController.navigate(Routes.SEARCH.route) + }, onEateryClick = { + FirstTimeShown.firstTimeShown = false + navController.navigate("${Routes.EATERY_DETAIL.route}/${it.id}") + }, onFavoriteExpand = { + navController.navigate(Routes.FAVORITES.route) + }, onCompareMenusClick = { selectedEateries -> + navController.navigate("comparemenus/${selectedEateries.joinToString(",") { it.toString() }}") + }, onNotificationsClick = { navController.navigate("notifications_home") } @@ -281,19 +287,29 @@ fun SetupNavHost( defaultValue = true }), enterTransition = { + webViewEnabled.value = true fadeIn( initialAlpha = 0f, animationSpec = tween(durationMillis = 500) ) }, exitTransition = { + webViewEnabled.value = false + if (loginViewModel.state.value is LoginViewModel.State.Login) { + // not yet logged in, so reset. + loginViewModel.resetLogin() + } fadeOut( animationSpec = tween(durationMillis = 500) ) }) { ProfileScreen( loginViewModel = loginViewModel, - onSettingsClicked = { navController.navigate(Routes.SETTINGS.route) } + onSettingsClicked = { navController.navigate(Routes.SETTINGS.route) }, + webViewEnabled = webViewEnabled.value, + onBackClick = { + navController.popBackStack() + } ) } composable( @@ -375,12 +391,13 @@ fun SetupNavHost( animationSpec = tween(durationMillis = 500) ) }) { - FavoritesScreen(onEateryClick = { - navController.navigate("${Routes.EATERY_DETAIL.route}/${it.id}") - }, onSearchClick = { - FirstTimeShown.firstTimeShown = false - navController.navigate(Routes.SEARCH.route) - }, + FavoritesScreen( + onEateryClick = { + navController.navigate("${Routes.EATERY_DETAIL.route}/${it.id}") + }, onSearchClick = { + FirstTimeShown.firstTimeShown = false + navController.navigate(Routes.SEARCH.route) + }, onBackClick = { navController.popBackStack() }) @@ -509,6 +526,6 @@ private fun currentRoute(navController: NavHostController): String? { fun NavController.isOnBackStack(route: String): Boolean = try { getBackStackEntry(route); true -} catch (e: Throwable) { +} catch (_: Throwable) { false } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index f94f08a1..be0d957f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -1,30 +1,28 @@ package com.cornellappdev.android.eatery.ui.screens import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.components.login.LoginPageContent -import com.cornellappdev.android.eatery.ui.components.login.LoginToast import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel @@ -36,25 +34,17 @@ import com.cornellappdev.android.eatery.util.EateryPreview fun ProfileScreen( loginViewModel: LoginViewModel, onSettingsClicked: () -> Unit, + webViewEnabled: Boolean, + onBackClick: () -> Unit ) { val state = loginViewModel.state.collectAsState().value ProfileScreenContent( state, loginPage = @Composable { - val context = LocalContext.current LoginPage( loginState = state as LoginViewModel.State.Login, loginViewModel = loginViewModel, - onWrongCredentials = { - LoginToast( - context, - "NetID and/or password incorrect", - R.drawable.ic_error, - R.color.light_red, - R.color.red - ) - loginViewModel.onLoginFailed() - } + webViewEnabled = webViewEnabled ) }, accountPage = @Composable { @@ -63,7 +53,7 @@ fun ProfileScreen( loginViewModel = loginViewModel, onSettingsClicked = { onSettingsClicked() }) }, - onSettingsClicked + onBackClick = onBackClick ) } @@ -72,58 +62,56 @@ private fun ProfileScreenContent( state: LoginViewModel.State, loginPage: @Composable () -> Unit, accountPage: @Composable () -> Unit, - onSettingsClicked: () -> Unit + onBackClick: () -> Unit ) { - Column(modifier = Modifier.background(Color.White)) { - when (state) { - is LoginViewModel.State.Login -> { - Column( + when (state) { + is LoginViewModel.State.Login -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = 7.dp, + start = 16.dp, + end = 16.dp + ) + .then(Modifier.statusBarsPadding()) + ) { + Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = 7.dp) - .then(Modifier.statusBarsPadding()) + .padding(top = 12.dp, bottom = 2.dp) + .height(34.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically ) { IconButton( - modifier = Modifier - .padding(end = 16.dp) - .align(Alignment.End) - .size(32.dp) - .statusBarsPadding(), - onClick = { onSettingsClicked() }) { - Icon( - modifier = Modifier.size(28.dp), - imageVector = Icons.Outlined.Settings, - contentDescription = Icons.Outlined.Settings.name, - tint = Color.Black - ) - } - Column( - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 24.dp - ) + onClick = { onBackClick() }, + modifier = Modifier.size(24.dp, 24.dp) ) { - Text( - text = "Log in with Eatery", - color = EateryBlue, - style = EateryBlueTypography.h3 + Image( + painter = painterResource(R.drawable.ic_left_chevron), + contentDescription = "Back Arrow" ) } } + Text( + text = "Log into Eatery", + color = EateryBlue, + style = EateryBlueTypography.h3 + ) loginPage() } + } - is LoginViewModel.State.Account -> { - accountPage() - } + is LoginViewModel.State.Account -> { + accountPage() } } } @Preview @Composable -private fun ProfileScreenPreview() = EateryPreview { +private fun ProfileLoginScreenPreview() = EateryPreview { val state = LoginViewModel.State.Login( netid = "aaa00", password = "myVeryLongPassword", @@ -135,14 +123,12 @@ private fun ProfileScreenPreview() = EateryPreview { loginPage = { LoginPageContent( loginState = state, - onWrongCredentials = {}, - onNetIdTyped = {}, - onPasswordTyped = {}, onLoginPressed = {}, - getUser = null + getUser = null, + webViewEnabled = false ) }, accountPage = { }, - onSettingsClicked = { } + onBackClick = { } ) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index c3db08da..ea154cff 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -45,7 +45,7 @@ class LoginViewModel @Inject constructor( private var _state = MutableStateFlow( if (CurrentUser.user == null) { - State.Login("", "", null, false) + defaultState() } else { State.Account(CurrentUser.user!!, "", AccountType.BRBS) } @@ -67,6 +67,12 @@ class LoginViewModel @Inject constructor( AccountType.OFF_CAMPUS ) + fun resetLogin() { + _state.value = defaultState() + } + + private fun defaultState() = State.Login("", "", null, false) + // Check what the meal plan is against our list of meal plans fun checkMealPlan(): Account? { if (_state.value !is State.Account || CurrentUser.user == null) return null @@ -112,41 +118,6 @@ class LoginViewModel @Inject constructor( } ?: listOf() } - fun onNetIDTyped(newNetid: String) { - val currState = _state.value - if (currState !is State.Login) return - - // currState is a Login state (expected). - val loginState = currState - val newState = State.Login( - newNetid, - loginState.password, - loginState.failureMessage, - false // Should never be able to type in when loading, anyways. - ) - - // Send the new netID Login state down. - _state.value = newState - } - - fun onPasswordTyped(newPassword: String) { - val currState = _state.value - if (currState !is State.Login) return - - // currState is a Login state (expected). - val loginState = currState - - val newState = State.Login( - loginState.netid, - newPassword, - loginState.failureMessage, - false // Should never be able to type in when loading, anyways. - ) - - // Send the new password Login state down. - _state.value = newState - } - fun onLoginPressed() { val currState = _state.value if (currState !is State.Login) return @@ -166,21 +137,6 @@ class LoginViewModel @Inject constructor( } - fun onLoginFailed() { - val currState = _state.value - if (currState !is State.Login) return - - val loginState = currState - - val newState = State.Login( - loginState.netid, - password = "", - failureMessage = "", - false - ) - _state.value = newState - } - fun onLogoutPressed() { val newState = State.Login( "", "", null, false From eb71c2be5664ce7c45ba721bd8e4385b97bba9ef Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 6 Sep 2025 22:56:18 -0400 Subject: [PATCH 015/126] Remove lint suppression, add default values for Login --- .../eatery/ui/components/login/LoginPage.kt | 4 +--- .../android/eatery/ui/screens/ProfileScreen.kt | 2 +- .../eatery/ui/viewmodels/LoginViewModel.kt | 18 ++++++++---------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 55f37765..5d92ca5f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -1,6 +1,5 @@ package com.cornellappdev.android.eatery.ui.components.login -import android.annotation.SuppressLint import android.view.View import android.view.ViewGroup import android.webkit.CookieManager @@ -137,7 +136,6 @@ fun LoginPageContent( * [onSuccess] is called after [onLoggedIn] when we have grabbed the sessionID from the * validation page after log in. */ -@SuppressLint("SetJavaScriptEnabled") @Composable private fun LoginWebView( onLoggedIn: () -> Unit, @@ -188,7 +186,7 @@ private class CustomWebViewClient( private fun LoginPagePreview() = EateryPreview { LoginPageContent( loginState = LoginViewModel.State.Login( - netid = "aaa00", + netID = "aaa00", password = "myVeryLongPassword", failureMessage = null, loading = false diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index be0d957f..5b7026cd 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -113,7 +113,7 @@ private fun ProfileScreenContent( @Composable private fun ProfileLoginScreenPreview() = EateryPreview { val state = LoginViewModel.State.Login( - netid = "aaa00", + netID = "aaa00", password = "myVeryLongPassword", failureMessage = null, loading = false diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index ea154cff..942286d7 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -30,10 +30,10 @@ class LoginViewModel @Inject constructor( */ sealed class State { data class Login( - val netid: String, - val password: String, - val failureMessage: String?, - val loading: Boolean + val netID: String = "", + val password: String = "", + val failureMessage: String? = null, + val loading: Boolean = false ) : State() data class Account( @@ -45,7 +45,7 @@ class LoginViewModel @Inject constructor( private var _state = MutableStateFlow( if (CurrentUser.user == null) { - defaultState() + State.Login() } else { State.Account(CurrentUser.user!!, "", AccountType.BRBS) } @@ -68,11 +68,9 @@ class LoginViewModel @Inject constructor( ) fun resetLogin() { - _state.value = defaultState() + _state.value = State.Login() } - private fun defaultState() = State.Login("", "", null, false) - // Check what the meal plan is against our list of meal plans fun checkMealPlan(): Account? { if (_state.value !is State.Account || CurrentUser.user == null) return null @@ -126,7 +124,7 @@ class LoginViewModel @Inject constructor( val loginState = currState val newState = State.Login( - loginState.netid, + loginState.netID, loginState.password, loginState.failureMessage, true // Should never be able to type in when loading, anyways. @@ -185,7 +183,7 @@ class LoginViewModel @Inject constructor( val currState = _state.value if (currState is State.Login) { val newState = State.Login( - netid = currState.netid, + netID = currState.netID, password = currState.password, failureMessage = e.stackTraceToString(), loading = false From 2831c4f17ed1131f1660868e6ae554782b93a40d Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 6 Sep 2025 23:04:53 -0400 Subject: [PATCH 016/126] Minor syntax improvement, add clarifying comment --- .../eatery/ui/navigation/MainTabbedNavigation.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt index a9098138..94a8eaef 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -169,7 +170,8 @@ fun SetupNavHost( AppStoreRatingPopup(navigateToSupport = { navController.navigate(Routes.SUPPORT.route) }) // Need to handle here so the webview is destroyed before navigating away from profile. - val webViewEnabled = remember { mutableStateOf(true) } + // Otherwise it causes a crash when navigating away from the webview. + var webViewEnabled by remember { mutableStateOf(true) } // The starting destination switches to onboarding if it isn't completed. AnimatedNavHost( @@ -287,14 +289,14 @@ fun SetupNavHost( defaultValue = true }), enterTransition = { - webViewEnabled.value = true + webViewEnabled = true fadeIn( initialAlpha = 0f, animationSpec = tween(durationMillis = 500) ) }, exitTransition = { - webViewEnabled.value = false + webViewEnabled = false if (loginViewModel.state.value is LoginViewModel.State.Login) { // not yet logged in, so reset. loginViewModel.resetLogin() @@ -306,7 +308,7 @@ fun SetupNavHost( ProfileScreen( loginViewModel = loginViewModel, onSettingsClicked = { navController.navigate(Routes.SETTINGS.route) }, - webViewEnabled = webViewEnabled.value, + webViewEnabled = webViewEnabled, onBackClick = { navController.popBackStack() } From 5436153bbcbb4450455a5520906b6d40fce6339c Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 6 Sep 2025 23:19:49 -0400 Subject: [PATCH 017/126] Minor reorganization --- .../android/eatery/ui/components/login/LoginPage.kt | 12 ++++-------- .../android/eatery/ui/screens/ProfileScreen.kt | 2 +- .../android/eatery/ui/viewmodels/LoginViewModel.kt | 13 ++++++------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 5d92ca5f..32ddc2d4 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -43,7 +43,6 @@ import com.cornellappdev.android.eatery.util.EateryPreview import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer -import kotlinx.coroutines.Job @Composable fun LoginPage( @@ -54,7 +53,7 @@ fun LoginPage( LoginPageContent( loginState = loginState, onLoginPressed = loginViewModel::onLoginPressed, - getUser = loginViewModel::getUser, + onSuccess = loginViewModel::onLoginWebViewSuccess, webViewEnabled = webViewEnabled ) } @@ -63,7 +62,7 @@ fun LoginPage( fun LoginPageContent( loginState: LoginViewModel.State.Login, onLoginPressed: () -> Unit, - getUser: ((String) -> Job)?, + onSuccess: (String) -> Unit, webViewEnabled: Boolean ) { val shimmer = rememberShimmer(ShimmerBounds.View) @@ -79,10 +78,7 @@ fun LoginPageContent( onLoggedIn = { loggedIn.value = true }, - onSuccess = { sessionId -> - getUser?.invoke(sessionId) - onLoginPressed() - } + onSuccess = onSuccess, ) return } @@ -192,7 +188,7 @@ private fun LoginPagePreview() = EateryPreview { loading = false ), onLoginPressed = {}, - getUser = null, + onSuccess = {}, webViewEnabled = false ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 5b7026cd..869e1baf 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -124,7 +124,7 @@ private fun ProfileLoginScreenPreview() = EateryPreview { LoginPageContent( loginState = state, onLoginPressed = {}, - getUser = null, + onSuccess = {}, webViewEnabled = false ) }, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 942286d7..4ada3f70 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -9,7 +9,6 @@ import com.cornellappdev.android.eatery.data.models.User import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.screens.CurrentUser -import com.cornellappdev.android.eatery.util.AppStorePopupRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,7 +21,6 @@ import javax.inject.Inject class LoginViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, private val userRepository: UserRepository, - private val appStorePopupRepository: AppStorePopupRepository, ) : ViewModel() { /** @@ -132,13 +130,10 @@ class LoginViewModel @Inject constructor( // Send the new loading Login state down _state.value = newState - } fun onLogoutPressed() { - val newState = State.Login( - "", "", null, false - ) + val newState = State.Login() _state.value = newState viewModelScope.launch { CurrentUser.user = null @@ -158,7 +153,11 @@ class LoginViewModel @Inject constructor( } } - fun getUser(sessionId: String) = viewModelScope.launch { + fun onLoginWebViewSuccess(sessionId: String) { + getUser(sessionId) + } + + private fun getUser(sessionId: String) = viewModelScope.launch { try { val currState = _state.value val user = userRepository.getUser(sessionId).response!! From 6d2a47090d6fa1679e12b3a2b48616bbd587d9bc Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 7 Sep 2025 04:04:17 -0400 Subject: [PATCH 018/126] Embed login webview inside bottom sheet --- .../eatery/ui/components/login/LoginPage.kt | 168 ++++++++++++------ .../eatery/ui/screens/ProfileScreen.kt | 67 +------ 2 files changed, 123 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 32ddc2d4..f718a21b 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -6,16 +6,25 @@ import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.IconButton +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -48,81 +57,135 @@ import com.valentinilk.shimmer.shimmer fun LoginPage( loginState: LoginViewModel.State.Login, loginViewModel: LoginViewModel, - webViewEnabled: Boolean + webViewEnabled: Boolean, + onBackClick: () -> Unit ) { LoginPageContent( loginState = loginState, onLoginPressed = loginViewModel::onLoginPressed, onSuccess = loginViewModel::onLoginWebViewSuccess, - webViewEnabled = webViewEnabled + webViewEnabled = webViewEnabled, + onBackClick = onBackClick ) } +@OptIn(ExperimentalMaterialApi::class) @Composable fun LoginPageContent( loginState: LoginViewModel.State.Login, onLoginPressed: () -> Unit, onSuccess: (String) -> Unit, - webViewEnabled: Boolean + webViewEnabled: Boolean, + onBackClick: () -> Unit ) { - val shimmer = rememberShimmer(ShimmerBounds.View) - val shimmerModifier = - if (loginState.loading) Modifier.shimmer(customShimmer = shimmer) else Modifier - val clickable = !loginState.loading val loggedIn = remember { mutableStateOf(false) } + + if (loginState.loading && !loggedIn.value && webViewEnabled) { + ModalBottomSheetLayout( + sheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Expanded + ), + sheetShape = RoundedCornerShape( + bottomStart = 0.dp, + bottomEnd = 0.dp, + topStart = 12.dp, + topEnd = 12.dp + ), + sheetElevation = 8.dp, + sheetContent = { + LoginWebView( + onLoggedIn = { + loggedIn.value = true + }, + onSuccess = onSuccess, + ) + } + ) {} + return + } Column( - modifier = Modifier.zIndex(1f) - ) { - if (loginState.loading && !loggedIn.value && webViewEnabled) { - LoginWebView( - onLoggedIn = { - loggedIn.value = true - }, - onSuccess = onSuccess, + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = 7.dp, + start = 16.dp, + end = 16.dp ) - return + .then(Modifier.statusBarsPadding()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 2.dp) + .height(34.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier.size(24.dp, 24.dp) + ) { + Image( + painter = painterResource(R.drawable.ic_left_chevron), + contentDescription = "Back Arrow" + ) + } } Text( - text = "Log in with your Cornell NetID to see your account balance and history", - style = TextStyle(fontWeight = FontWeight.Medium, fontSize = 18.sp), - color = GraySix, - modifier = Modifier.padding(top = 7.dp) + text = "Log into Eatery", + color = EateryBlue, + style = EateryBlueTypography.h3 ) + val shimmer = rememberShimmer(ShimmerBounds.View) + val shimmerModifier = + if (loginState.loading) Modifier.shimmer(customShimmer = shimmer) else Modifier + val clickable = !loginState.loading - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(id = R.drawable.ic_eaterylogo), - contentDescription = "Eatery logo", - modifier = Modifier - .fillMaxWidth(0.5f) - .fillMaxHeight(), - colorFilter = ColorFilter.tint(Color(0xFFB7D3F3)) - ) - } - Button( - enabled = clickable, - shape = RoundedCornerShape(24.dp), - modifier = Modifier - .fillMaxWidth() - .then(shimmerModifier) - .height(56.dp), - onClick = { - onLoginPressed() - }, - colors = ButtonDefaults.buttonColors( - backgroundColor = if (clickable) EateryBlue else GrayZero - ), - elevation = ButtonDefaults.elevation(defaultElevation = 0.dp) + Column( + modifier = Modifier.zIndex(1f) ) { Text( - text = if (loginState.loading) "Logging in..." else "Log in", - color = if (clickable) Color.White else GrayThree, - style = EateryBlueTypography.h5 + text = "Log in with your Cornell NetID to see your account balance and history", + style = TextStyle(fontWeight = FontWeight.Medium, fontSize = 18.sp), + color = GraySix, + modifier = Modifier.padding(top = 7.dp) ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_eaterylogo), + contentDescription = "Eatery logo", + modifier = Modifier + .fillMaxWidth(0.5f) + .fillMaxHeight(), + colorFilter = ColorFilter.tint(Color(0xFFB7D3F3)) + ) + } + Button( + enabled = clickable, + shape = RoundedCornerShape(24.dp), + modifier = Modifier + .fillMaxWidth() + .then(shimmerModifier) + .height(56.dp), + onClick = { + onLoginPressed() + }, + colors = ButtonDefaults.buttonColors( + backgroundColor = if (clickable) EateryBlue else GrayZero + ), + elevation = ButtonDefaults.elevation(defaultElevation = 0.dp) + ) { + Text( + text = if (loginState.loading) "Logging in..." else "Log in", + color = if (clickable) Color.White else GrayThree, + style = EateryBlueTypography.h5 + ) + } } } } @@ -189,6 +252,7 @@ private fun LoginPagePreview() = EateryPreview { ), onLoginPressed = {}, onSuccess = {}, - webViewEnabled = false + webViewEnabled = false, + onBackClick = {} ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 869e1baf..e33385a7 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -1,30 +1,12 @@ package com.cornellappdev.android.eatery.ui.screens import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material.IconButton -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.components.login.LoginPageContent -import com.cornellappdev.android.eatery.ui.theme.EateryBlue -import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel import com.cornellappdev.android.eatery.util.EateryPreview @@ -44,7 +26,8 @@ fun ProfileScreen( LoginPage( loginState = state as LoginViewModel.State.Login, loginViewModel = loginViewModel, - webViewEnabled = webViewEnabled + webViewEnabled = webViewEnabled, + onBackClick = onBackClick ) }, accountPage = @Composable { @@ -52,8 +35,7 @@ fun ProfileScreen( accountState = state as LoginViewModel.State.Account, loginViewModel = loginViewModel, onSettingsClicked = { onSettingsClicked() }) - }, - onBackClick = onBackClick + } ) } @@ -62,45 +44,10 @@ private fun ProfileScreenContent( state: LoginViewModel.State, loginPage: @Composable () -> Unit, accountPage: @Composable () -> Unit, - onBackClick: () -> Unit ) { when (state) { is LoginViewModel.State.Login -> { - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - bottom = 7.dp, - start = 16.dp, - end = 16.dp - ) - .then(Modifier.statusBarsPadding()) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = 2.dp) - .height(34.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = { onBackClick() }, - modifier = Modifier.size(24.dp, 24.dp) - ) { - Image( - painter = painterResource(R.drawable.ic_left_chevron), - contentDescription = "Back Arrow" - ) - } - } - Text( - text = "Log into Eatery", - color = EateryBlue, - style = EateryBlueTypography.h3 - ) - loginPage() - } + loginPage() } is LoginViewModel.State.Account -> { @@ -125,10 +72,10 @@ private fun ProfileLoginScreenPreview() = EateryPreview { loginState = state, onLoginPressed = {}, onSuccess = {}, - webViewEnabled = false + webViewEnabled = false, + onBackClick = {} ) }, - accountPage = { }, - onBackClick = { } + accountPage = { } ) } \ No newline at end of file From 834707c21b298099fd0a37c525ac61d4c9d7a51a Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Fri, 12 Sep 2025 22:32:48 -0400 Subject: [PATCH 019/126] Improve login modal UI and behavior --- .../eatery/ui/components/login/LoginPage.kt | 103 +++++++++++------- .../ui/navigation/MainTabbedNavigation.kt | 5 +- .../eatery/ui/screens/ProfileScreen.kt | 5 +- .../eatery/ui/viewmodels/LoginViewModel.kt | 22 ++-- 4 files changed, 84 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index f718a21b..149b7fdc 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -1,5 +1,6 @@ package com.cornellappdev.android.eatery.ui.components.login +import android.util.Log import android.view.View import android.view.ViewGroup import android.webkit.CookieManager @@ -26,8 +27,11 @@ import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -61,48 +65,77 @@ fun LoginPage( onBackClick: () -> Unit ) { LoginPageContent( - loginState = loginState, + loading = loginState.loading, onLoginPressed = loginViewModel::onLoginPressed, onSuccess = loginViewModel::onLoginWebViewSuccess, webViewEnabled = webViewEnabled, - onBackClick = onBackClick + onBackClick = onBackClick, + onModalHidden = loginViewModel::onLoginExited ) } @OptIn(ExperimentalMaterialApi::class) @Composable fun LoginPageContent( - loginState: LoginViewModel.State.Login, + loading: Boolean, onLoginPressed: () -> Unit, onSuccess: (String) -> Unit, webViewEnabled: Boolean, - onBackClick: () -> Unit + onBackClick: () -> Unit, + onModalHidden: () -> Unit ) { - val loggedIn = remember { mutableStateOf(false) } + var loggedIn by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState( + ModalBottomSheetValue.Hidden + ) + var webViewExpanded by remember { mutableStateOf(false) } + if (!sheetState.isVisible && loading) { + if (webViewExpanded) { + onModalHidden() + } + webViewExpanded = false + } else if (sheetState.isVisible) { + Log.d("debug", "visible") + webViewExpanded = true + } - if (loginState.loading && !loggedIn.value && webViewEnabled) { - ModalBottomSheetLayout( - sheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Expanded - ), - sheetShape = RoundedCornerShape( - bottomStart = 0.dp, - bottomEnd = 0.dp, - topStart = 12.dp, - topEnd = 12.dp - ), - sheetElevation = 8.dp, - sheetContent = { - LoginWebView( - onLoggedIn = { - loggedIn.value = true - }, - onSuccess = onSuccess, - ) - } - ) {} - return + Log.d("debug", "loading: $loading") + if (loading && !loggedIn && webViewEnabled) { + Log.d("debug", "show sheet") + LaunchedEffect(true) { + sheetState.show() + } + } + + ModalBottomSheetLayout( + sheetState = sheetState, + sheetShape = RoundedCornerShape( + bottomStart = 0.dp, + bottomEnd = 0.dp, + topStart = 12.dp, + topEnd = 12.dp + ), + sheetElevation = 8.dp, + sheetContent = { + LoginWebView( + onLoggedIn = { + loggedIn = true + }, + onSuccess = onSuccess, + ) + }, + modifier = Modifier.statusBarsPadding() + ) { + LoginPageMainLayer(onBackClick, loading, onLoginPressed) } +} + +@Composable +private fun LoginPageMainLayer( + onBackClick: () -> Unit, + loading: Boolean, + onLoginPressed: () -> Unit +) { Column( modifier = Modifier .fillMaxWidth() @@ -138,8 +171,8 @@ fun LoginPageContent( ) val shimmer = rememberShimmer(ShimmerBounds.View) val shimmerModifier = - if (loginState.loading) Modifier.shimmer(customShimmer = shimmer) else Modifier - val clickable = !loginState.loading + if (loading) Modifier.shimmer(customShimmer = shimmer) else Modifier + val clickable = !loading Column( modifier = Modifier.zIndex(1f) @@ -181,7 +214,7 @@ fun LoginPageContent( elevation = ButtonDefaults.elevation(defaultElevation = 0.dp) ) { Text( - text = if (loginState.loading) "Logging in..." else "Log in", + text = if (loading) "Logging in..." else "Log in", color = if (clickable) Color.White else GrayThree, style = EateryBlueTypography.h5 ) @@ -244,15 +277,11 @@ private class CustomWebViewClient( @Composable private fun LoginPagePreview() = EateryPreview { LoginPageContent( - loginState = LoginViewModel.State.Login( - netID = "aaa00", - password = "myVeryLongPassword", - failureMessage = null, - loading = false - ), + loading = false, onLoginPressed = {}, onSuccess = {}, webViewEnabled = false, - onBackClick = {} + onBackClick = {}, + onModalHidden = {} ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt index 94a8eaef..5a771cd3 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt @@ -305,10 +305,13 @@ fun SetupNavHost( animationSpec = tween(durationMillis = 500) ) }) { + // need this for when user navigates from profile to itself + // since no guarantee of order between enterTransition and exitTransition + webViewEnabled = true ProfileScreen( loginViewModel = loginViewModel, onSettingsClicked = { navController.navigate(Routes.SETTINGS.route) }, - webViewEnabled = webViewEnabled, + webViewEnabled = true, onBackClick = { navController.popBackStack() } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index e33385a7..2d270164 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -69,11 +69,12 @@ private fun ProfileLoginScreenPreview() = EateryPreview { state = state, loginPage = { LoginPageContent( - loginState = state, + loading = false, onLoginPressed = {}, onSuccess = {}, webViewEnabled = false, - onBackClick = {} + onBackClick = {}, + onModalHidden = {} ) }, accountPage = { } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 4ada3f70..a5eb54f1 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -115,21 +115,20 @@ class LoginViewModel @Inject constructor( } fun onLoginPressed() { - val currState = _state.value - if (currState !is State.Login) return + loadLogin(true) + } - // currState is a Login state (expected). - val loginState = currState + fun onLoginExited() { + loadLogin(false) + } - val newState = State.Login( - loginState.netID, - loginState.password, - loginState.failureMessage, - true // Should never be able to type in when loading, anyways. - ) + private fun loadLogin(loading: Boolean) { + val currState = _state.value + if (currState !is State.Login) return // Send the new loading Login state down - _state.value = newState + _state.value = currState.copy(loading = loading) + } fun onLogoutPressed() { @@ -194,3 +193,4 @@ class LoginViewModel @Inject constructor( } } } + From 9d9b764eda88a0d3dd8b1f4119d8e54dc8911db4 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 13 Sep 2025 02:00:45 -0400 Subject: [PATCH 020/126] Fix cache error and post-login behavior --- .../eatery/ui/components/login/LoginPage.kt | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 149b7fdc..13f77374 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -1,9 +1,7 @@ package com.cornellappdev.android.eatery.ui.components.login -import android.util.Log import android.view.View import android.view.ViewGroup -import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.Image @@ -86,22 +84,25 @@ fun LoginPageContent( ) { var loggedIn by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState( - ModalBottomSheetValue.Hidden + initialValue = ModalBottomSheetValue.Hidden, + skipHalfExpanded = true ) var webViewExpanded by remember { mutableStateOf(false) } - if (!sheetState.isVisible && loading) { + if (!sheetState.isVisible && loading && !loggedIn) { if (webViewExpanded) { + // only run if user manually hid the screen + // and not if it was already hidden onModalHidden() } webViewExpanded = false + } else if (loggedIn) { + LaunchedEffect(true) { + sheetState.hide() + } } else if (sheetState.isVisible) { - Log.d("debug", "visible") webViewExpanded = true } - - Log.d("debug", "loading: $loading") - if (loading && !loggedIn && webViewEnabled) { - Log.d("debug", "show sheet") + if (loading && !loggedIn && webViewEnabled && !sheetState.isVisible) { LaunchedEffect(true) { sheetState.show() } @@ -118,10 +119,8 @@ fun LoginPageContent( sheetElevation = 8.dp, sheetContent = { LoginWebView( - onLoggedIn = { - loggedIn = true - }, - onSuccess = onSuccess, + onLoggedIn = { loggedIn = true }, + onSuccess = onSuccess ) }, modifier = Modifier.statusBarsPadding() @@ -231,10 +230,8 @@ private fun LoginPageMainLayer( @Composable private fun LoginWebView( onLoggedIn: () -> Unit, - onSuccess: (String) -> Unit, + onSuccess: (String) -> Unit ) { - CookieManager.getInstance().removeAllCookies(null) - CookieManager.getInstance().flush() AndroidView( factory = { WebView(it).apply { @@ -248,7 +245,7 @@ private fun LoginWebView( webViewClient = CustomWebViewClient(onSuccess, onLoggedIn) loadUrl(BuildConfig.SESSIONID_WEBVIEW_URL) } - }, + } ) } From fe98859b48316658109835335f8e3f1aa6e5b0a3 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 14 Sep 2025 00:17:37 -0400 Subject: [PATCH 021/126] Improve function signature --- .../android/eatery/ui/viewmodels/LoginViewModel.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index a5eb54f1..24ab6273 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -115,19 +115,19 @@ class LoginViewModel @Inject constructor( } fun onLoginPressed() { - loadLogin(true) + updateLoginLoadingState(true) } fun onLoginExited() { - loadLogin(false) + updateLoginLoadingState(false) } - private fun loadLogin(loading: Boolean) { + private fun updateLoginLoadingState(isLoading: Boolean) { val currState = _state.value if (currState !is State.Login) return // Send the new loading Login state down - _state.value = currState.copy(loading = loading) + _state.value = currState.copy(loading = isLoading) } From 42a4cd288365e26bc61221d227784a29694ac32d Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 14 Sep 2025 15:21:24 -0400 Subject: [PATCH 022/126] Reduce view model and composable arguments in composables --- .../eatery/ui/components/login/AccountPage.kt | 76 ++++++++++------- .../eatery/ui/components/login/LoginPage.kt | 62 ++++++-------- .../eatery/ui/screens/ProfileScreen.kt | 85 ++++++++++++------- 3 files changed, 122 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 4e890a89..54037bee 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -53,13 +53,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.cornellappdev.android.eatery.R +import com.cornellappdev.android.eatery.data.models.Account import com.cornellappdev.android.eatery.data.models.AccountType +import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.ui.components.general.SearchBar import com.cornellappdev.android.eatery.ui.components.home.BottomSheetContent import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.theme.GrayZero -import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -70,9 +71,12 @@ import java.time.format.DateTimeFormatter ) @Composable fun AccountPage( - accountState: LoginViewModel.State.Account, - loginViewModel: LoginViewModel, - onSettingsClicked: () -> Unit + accountFilter: AccountType, + checkAccount: (AccountType) -> Account?, + checkMealPlan: () -> Account?, + onSettingsClicked: () -> Unit, + getTransactionsOfType: (AccountType, String) -> List, + updateAccountFilter: (AccountType) -> Unit ) { var filterText by remember { mutableStateOf("") } val modalBottomSheetState = @@ -87,19 +91,20 @@ fun AccountPage( sheetContent = { when (sheetContent) { BottomSheetContent.ACCOUNT_TYPE -> { - AccountTypesAvailable(selectedPaymentMethod = listOf( - AccountType.MEALSWIPES, - AccountType.BRBS, - AccountType.CITYBUCKS, - AccountType.LAUNDRY - ), - accountState = accountState, + AccountTypesAvailable( + selectedPaymentMethod = listOf( + AccountType.MEALSWIPES, + AccountType.BRBS, + AccountType.CITYBUCKS, + AccountType.LAUNDRY + ), + accountFilter = accountFilter, hide = { coroutineScope.launch { modalBottomSheetState.hide() } }) { - loginViewModel.updateAccountFilter(it) + updateAccountFilter(it) } } @@ -207,8 +212,7 @@ fun AccountPage( LazyColumn(state = innerListState) { item { (Column(modifier = Modifier.padding(horizontal = 16.dp)) { - Column( - ) { + Column { Text( text = "Meal Plan", style = EateryBlueTypography.h4, @@ -217,7 +221,8 @@ fun AccountPage( AccountBalanceRow( accountName = "Meal Swipes", accountType = AccountType.MEALSWIPES, - loginViewModel = loginViewModel + checkAccount = checkAccount, + checkMealPlan = checkMealPlan ) Spacer( modifier = Modifier @@ -228,7 +233,8 @@ fun AccountPage( AccountBalanceRow( accountName = "Big Red Bucks", accountType = AccountType.BRBS, - loginViewModel = loginViewModel + checkAccount = checkAccount, + checkMealPlan = checkMealPlan ) Spacer( modifier = Modifier @@ -239,7 +245,8 @@ fun AccountPage( AccountBalanceRow( accountName = "City Bucks", accountType = AccountType.CITYBUCKS, - loginViewModel = loginViewModel + checkAccount = checkAccount, + checkMealPlan = checkMealPlan ) Spacer( modifier = Modifier @@ -250,7 +257,8 @@ fun AccountPage( AccountBalanceRow( accountName = "Laundry", accountType = AccountType.LAUNDRY, - loginViewModel = loginViewModel + checkAccount = checkAccount, + checkMealPlan = checkMealPlan ) } }) @@ -278,7 +286,7 @@ fun AccountPage( modifier = Modifier.weight(1f) ) { Text( - text = when (accountState.accountFilter.name) { + text = when (accountFilter.name) { "MEALSWIPES" -> "Meal Swipes" "BRBS" -> "Big Red Bucks" "LAUNDRY" -> "Laundry" @@ -341,8 +349,8 @@ fun AccountPage( } } items( - loginViewModel.getTransactionsOfType( - accountState.accountFilter, + getTransactionsOfType( + accountFilter, filterText ) ) { it -> @@ -410,7 +418,8 @@ fun AccountPage( fun AccountBalanceRow( accountName: String, accountType: AccountType, - loginViewModel: LoginViewModel + checkAccount: (AccountType) -> Account?, + checkMealPlan: () -> Account? ) { Row( modifier = Modifier.height(50.dp), @@ -426,11 +435,11 @@ fun AccountBalanceRow( textAlign = TextAlign.Right, text = if (accountType != AccountType.MEALSWIPES) { "$" + "%.2f".format( - loginViewModel.checkAccount(accountType)?.balance?.toFloat() ?: 0f + checkAccount(accountType)?.balance?.toFloat() ?: 0f ) } else { "%.0f".format( - loginViewModel.checkMealPlan()?.balance?.toFloat() ?: 0f + checkMealPlan()?.balance?.toFloat() ?: 0f ) + " remaining" }, style = EateryBlueTypography.button, @@ -442,11 +451,11 @@ fun AccountBalanceRow( @Composable fun AccountTypesAvailable( selectedPaymentMethod: List, - accountState: LoginViewModel.State.Account, + accountFilter: AccountType, hide: () -> Unit, onSubmit: (AccountType) -> Unit ) { - var selected by remember { mutableStateOf(accountState.accountFilter) } + var selected by remember { mutableStateOf(accountFilter) } Column( modifier = Modifier .padding(vertical = 24.dp) @@ -480,13 +489,14 @@ fun AccountTypesAvailable( account -> true else -> false } - Row(modifier = Modifier - .height(63.dp) - .fillMaxWidth() - .selectable( - selected = (select), - onClick = { selected = account } - ), + Row( + modifier = Modifier + .height(63.dp) + .fillMaxWidth() + .selectable( + selected = (select), + onClick = { selected = account } + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 13f77374..6a01cd78 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -49,32 +50,14 @@ import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.theme.GraySix import com.cornellappdev.android.eatery.ui.theme.GrayThree import com.cornellappdev.android.eatery.ui.theme.GrayZero -import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel import com.cornellappdev.android.eatery.util.EateryPreview import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer -@Composable -fun LoginPage( - loginState: LoginViewModel.State.Login, - loginViewModel: LoginViewModel, - webViewEnabled: Boolean, - onBackClick: () -> Unit -) { - LoginPageContent( - loading = loginState.loading, - onLoginPressed = loginViewModel::onLoginPressed, - onSuccess = loginViewModel::onLoginWebViewSuccess, - webViewEnabled = webViewEnabled, - onBackClick = onBackClick, - onModalHidden = loginViewModel::onLoginExited - ) -} - @OptIn(ExperimentalMaterialApi::class) @Composable -fun LoginPageContent( +fun LoginPage( loading: Boolean, onLoginPressed: () -> Unit, onSuccess: (String) -> Unit, @@ -107,24 +90,27 @@ fun LoginPageContent( sheetState.show() } } - - ModalBottomSheetLayout( - sheetState = sheetState, - sheetShape = RoundedCornerShape( - bottomStart = 0.dp, - bottomEnd = 0.dp, - topStart = 12.dp, - topEnd = 12.dp - ), - sheetElevation = 8.dp, - sheetContent = { - LoginWebView( - onLoggedIn = { loggedIn = true }, - onSuccess = onSuccess - ) - }, - modifier = Modifier.statusBarsPadding() - ) { + if (!LocalInspectionMode.current) { + ModalBottomSheetLayout( + sheetState = sheetState, + sheetShape = RoundedCornerShape( + bottomStart = 0.dp, + bottomEnd = 0.dp, + topStart = 12.dp, + topEnd = 12.dp + ), + sheetElevation = 8.dp, + sheetContent = { + LoginWebView( + onLoggedIn = { loggedIn = true }, + onSuccess = onSuccess + ) + }, + modifier = Modifier.statusBarsPadding() + ) { + LoginPageMainLayer(onBackClick, loading, onLoginPressed) + } + } else { LoginPageMainLayer(onBackClick, loading, onLoginPressed) } } @@ -273,7 +259,7 @@ private class CustomWebViewClient( @Preview @Composable private fun LoginPagePreview() = EateryPreview { - LoginPageContent( + LoginPage( loading = false, onLoginPressed = {}, onSuccess = {}, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 2d270164..65d8c9d5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -4,9 +4,11 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.tooling.preview.Preview +import com.cornellappdev.android.eatery.data.models.Account +import com.cornellappdev.android.eatery.data.models.AccountType +import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage -import com.cornellappdev.android.eatery.ui.components.login.LoginPageContent import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel import com.cornellappdev.android.eatery.util.EateryPreview @@ -22,36 +24,58 @@ fun ProfileScreen( val state = loginViewModel.state.collectAsState().value ProfileScreenContent( state, - loginPage = @Composable { - LoginPage( - loginState = state as LoginViewModel.State.Login, - loginViewModel = loginViewModel, - webViewEnabled = webViewEnabled, - onBackClick = onBackClick - ) - }, - accountPage = @Composable { - AccountPage( - accountState = state as LoginViewModel.State.Account, - loginViewModel = loginViewModel, - onSettingsClicked = { onSettingsClicked() }) - } + loading = state is LoginViewModel.State.Login && state.loading, + onLoginPressed = loginViewModel::onLoginPressed, + onSuccess = loginViewModel::onLoginWebViewSuccess, + webViewEnabled = webViewEnabled, + onBackClick = onBackClick, + onModalHidden = loginViewModel::onLoginExited, + accountFilter = if (state is LoginViewModel.State.Account) state.accountFilter else AccountType.BRBS, + checkAccount = loginViewModel::checkAccount, + checkMealPlan = loginViewModel::checkMealPlan, + onSettingsClicked = onSettingsClicked, + getTransactionsOfType = loginViewModel::getTransactionsOfType, + updateAccountFilter = loginViewModel::updateAccountFilter ) } @Composable private fun ProfileScreenContent( state: LoginViewModel.State, - loginPage: @Composable () -> Unit, - accountPage: @Composable () -> Unit, + loading: Boolean, + onLoginPressed: () -> Unit, + onSuccess: (String) -> Unit, + webViewEnabled: Boolean, + onBackClick: () -> Unit, + onModalHidden: () -> Unit, + accountFilter: AccountType, + checkAccount: (AccountType) -> Account?, + checkMealPlan: () -> Account?, + onSettingsClicked: () -> Unit, + getTransactionsOfType: (AccountType, String) -> List, + updateAccountFilter: (AccountType) -> Unit ) { when (state) { is LoginViewModel.State.Login -> { - loginPage() + LoginPage( + loading = loading, + onLoginPressed = onLoginPressed, + onSuccess = onSuccess, + webViewEnabled = webViewEnabled, + onBackClick = onBackClick, + onModalHidden = onModalHidden + ) } is LoginViewModel.State.Account -> { - accountPage() + AccountPage( + accountFilter = accountFilter, + checkAccount = checkAccount, + checkMealPlan = checkMealPlan, + onSettingsClicked = onSettingsClicked, + getTransactionsOfType = getTransactionsOfType, + updateAccountFilter = updateAccountFilter + ) } } } @@ -67,16 +91,17 @@ private fun ProfileLoginScreenPreview() = EateryPreview { ) ProfileScreenContent( state = state, - loginPage = { - LoginPageContent( - loading = false, - onLoginPressed = {}, - onSuccess = {}, - webViewEnabled = false, - onBackClick = {}, - onModalHidden = {} - ) - }, - accountPage = { } + loading = false, + onLoginPressed = {}, + onSuccess = {}, + webViewEnabled = false, + onBackClick = {}, + onModalHidden = {}, + accountFilter = AccountType.BRBS, + checkAccount = { null }, + checkMealPlan = { null }, + onSettingsClicked = {}, + getTransactionsOfType = { _, _ -> emptyList() }, + updateAccountFilter = {}, ) } \ No newline at end of file From ec7ae5b557ee047830348ce80a1e8f3ff5db866e Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 14 Sep 2025 15:55:16 -0400 Subject: [PATCH 023/126] Add isPreview --- .../android/eatery/ui/components/login/LoginPage.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index 6a01cd78..a9a6f443 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -90,7 +90,7 @@ fun LoginPage( sheetState.show() } } - if (!LocalInspectionMode.current) { + if (!isPreview()) { ModalBottomSheetLayout( sheetState = sheetState, sheetShape = RoundedCornerShape( @@ -268,3 +268,6 @@ private fun LoginPagePreview() = EateryPreview { onModalHidden = {} ) } + +@Composable +private fun isPreview() = LocalInspectionMode.current \ No newline at end of file From 65539e0700414a190a0a3e2d648e7642e1d06ed4 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 14 Sep 2025 16:49:33 -0400 Subject: [PATCH 024/126] Fix log out button so the user stays logged out --- .../eatery/ui/components/login/LoginPage.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt index a9a6f443..754e8e14 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/LoginPage.kt @@ -2,6 +2,7 @@ package com.cornellappdev.android.eatery.ui.components.login import android.view.View import android.view.ViewGroup +import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.Image @@ -71,6 +72,7 @@ fun LoginPage( skipHalfExpanded = true ) var webViewExpanded by remember { mutableStateOf(false) } + var isFirstWebView by remember { mutableStateOf(true) } if (!sheetState.isVisible && loading && !loggedIn) { if (webViewExpanded) { // only run if user manually hid the screen @@ -103,7 +105,9 @@ fun LoginPage( sheetContent = { LoginWebView( onLoggedIn = { loggedIn = true }, - onSuccess = onSuccess + onSuccess = onSuccess, + isFirstWebView = isFirstWebView, + webViewLoaded = { isFirstWebView = false } ) }, modifier = Modifier.statusBarsPadding() @@ -216,8 +220,18 @@ private fun LoginPageMainLayer( @Composable private fun LoginWebView( onLoggedIn: () -> Unit, - onSuccess: (String) -> Unit + onSuccess: (String) -> Unit, + isFirstWebView: Boolean, + webViewLoaded: () -> Unit ) { + if (isFirstWebView) { + // If the web view is being loaded for the first time after user navigates to LoginPage, + // then reset cookies. This makes logging out work. + // We need the conditional since LoginWebView gets recomposed during login. + CookieManager.getInstance().removeAllCookies(null) + CookieManager.getInstance().flush() + webViewLoaded() + } AndroidView( factory = { WebView(it).apply { From a50221894e5cb2489921994ddf5e64706080e654 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 14 Sep 2025 16:51:14 -0400 Subject: [PATCH 025/126] Minor function syntax change --- .../android/eatery/ui/viewmodels/LoginViewModel.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 24ab6273..fed69b63 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -114,13 +114,9 @@ class LoginViewModel @Inject constructor( } ?: listOf() } - fun onLoginPressed() { - updateLoginLoadingState(true) - } + fun onLoginPressed() = updateLoginLoadingState(true) - fun onLoginExited() { - updateLoginLoadingState(false) - } + fun onLoginExited() = updateLoginLoadingState(false) private fun updateLoginLoadingState(isLoading: Boolean) { val currState = _state.value From 8324a297ef72fa7ad80a4caf256eca40f2c9083a Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 24 Sep 2025 18:16:13 -0400 Subject: [PATCH 026/126] Add switch for notifs button --- .../android/eatery/ui/screens/HomeScreen.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index fa71c806..9bb759ac 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -89,6 +89,7 @@ import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import kotlinx.coroutines.launch +private const val enableNotifs = false @OptIn( ExperimentalMaterialApi::class, @@ -584,15 +585,16 @@ private fun HomeStickyHeader( color = Color.White, style = EateryBlueTypography.h2 ) - Icon( - painter = painterResource(id = R.drawable.ic_bell), - contentDescription = null, - tint = Color.White, - modifier = Modifier.clickable { - onNotificationsClick() - } - ) - + if (enableNotifs) { + Icon( + painter = painterResource(id = R.drawable.ic_bell), + contentDescription = null, + tint = Color.White, + modifier = Modifier.clickable { + onNotificationsClick() + } + ) + } } } From 66d465f82b479161460c00e620daa1d3e9eab7a2 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Fri, 26 Sep 2025 13:50:54 -0400 Subject: [PATCH 027/126] Make eateries data refresh at onResume --- .../cornellappdev/android/eatery/MainActivity.kt | 16 +++++++++++++++- .../eatery/data/repositories/EateryRepository.kt | 4 ++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index ea79b955..8b5b9051 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -4,6 +4,9 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.view.WindowCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.ui.navigation.NavigationSetup import com.cornellappdev.android.eatery.util.LockScreenOrientation @@ -16,6 +19,12 @@ class MainActivity : ComponentActivity() { @Inject lateinit var userPreferences: UserPreferencesRepository + @Inject + lateinit var eateryRepository: EateryRepository + private val dataRefresher = DataRefresher(pingEateries = { + eateryRepository.pingEateries() + }) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -26,7 +35,7 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) // Want to eventually switch over to this typography - val typography = androidx.compose.material3.Typography() + androidx.compose.material3.Typography() setContent { LockScreenOrientation() @@ -34,5 +43,10 @@ class MainActivity : ComponentActivity() { NavigationSetup(hasOnboarded) } } + lifecycle.addObserver(dataRefresher) } } + +class DataRefresher(val pingEateries: () -> Unit) : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) = pingEateries() +} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index 60ef2a66..73650fb4 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -46,6 +46,10 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { init { // Start loading backend as soon as the app initializes. + pingEateries() + } + + fun pingEateries() { pingAllEateries() pingHomeEateries() } From 569cbdf46d7be9f9185bf0aaae6ea8924a0d444d Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Fri, 26 Sep 2025 14:14:08 -0400 Subject: [PATCH 028/126] Update typography --- .../java/com/cornellappdev/android/eatery/MainActivity.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 8b5b9051..205bb48a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -34,12 +34,11 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) - // Want to eventually switch over to this typography - androidx.compose.material3.Typography() + val typography = androidx.compose.material3.Typography() setContent { LockScreenOrientation() - androidx.compose.material3.MaterialTheme { + androidx.compose.material3.MaterialTheme(typography = typography) { NavigationSetup(hasOnboarded) } } From a4403b540557728357c08e06eaa41ab856f6c177 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Fri, 26 Sep 2025 14:27:42 -0400 Subject: [PATCH 029/126] Use buildConfigField for enabling notifications --- app/build.gradle | 2 ++ .../cornellappdev/android/eatery/ui/screens/HomeScreen.kt | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7d945ff6..8a391c1c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,11 +40,13 @@ android { debug { resValue("bool", "FIREBASE_ANALYTICS_DEACTIVATED", "true") buildConfigField("String", "BACKEND_URL", secretsProperties['BACKEND_URL']) + buildConfigField("boolean", "ENABLE_NOTIFICATIONS", "false") } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' buildConfigField("String", "BACKEND_URL", secretsProperties['PROD_ENDPOINT']) + buildConfigField("boolean", "ENABLE_NOTIFICATIONS", "false") } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 9bb759ac..3c6f9595 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import com.cornellappdev.android.eatery.BuildConfig import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.ui.components.comparemenus.CompareMenusBotSheet @@ -89,8 +90,6 @@ import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import kotlinx.coroutines.launch -private const val enableNotifs = false - @OptIn( ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class, @@ -585,7 +584,7 @@ private fun HomeStickyHeader( color = Color.White, style = EateryBlueTypography.h2 ) - if (enableNotifs) { + if (BuildConfig.ENABLE_NOTIFICATIONS) { Icon( painter = painterResource(id = R.drawable.ic_bell), contentDescription = null, From d6787b2f8f956c1a7d2135d25b19bef1d0da222e Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Fri, 26 Sep 2025 14:55:40 -0400 Subject: [PATCH 030/126] Inline DataRefresher --- .../cornellappdev/android/eatery/MainActivity.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 205bb48a..ee46a886 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -21,9 +21,6 @@ class MainActivity : ComponentActivity() { @Inject lateinit var eateryRepository: EateryRepository - private val dataRefresher = DataRefresher(pingEateries = { - eateryRepository.pingEateries() - }) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -42,10 +39,12 @@ class MainActivity : ComponentActivity() { NavigationSetup(hasOnboarded) } } + val dataRefresher = object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + eateryRepository.pingEateries() + } + } lifecycle.addObserver(dataRefresher) } } - -class DataRefresher(val pingEateries: () -> Unit) : DefaultLifecycleObserver { - override fun onResume(owner: LifecycleOwner) = pingEateries() -} From 96c211c9c494fd6bba02473e08a9f4bd11efde6b Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Fri, 26 Sep 2025 14:56:14 -0400 Subject: [PATCH 031/126] Set enableNotifications to true in debug --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 8a391c1c..9a0f9b74 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,7 +40,7 @@ android { debug { resValue("bool", "FIREBASE_ANALYTICS_DEACTIVATED", "true") buildConfigField("String", "BACKEND_URL", secretsProperties['BACKEND_URL']) - buildConfigField("boolean", "ENABLE_NOTIFICATIONS", "false") + buildConfigField("boolean", "ENABLE_NOTIFICATIONS", "true") } release { minifyEnabled false From 48bbf73c22375082c52b569ef6771c11bb401066 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 4 Oct 2025 23:07:30 -0400 Subject: [PATCH 032/126] Make eatery details refresh on resume --- .../com/cornellappdev/android/eatery/MainActivity.kt | 1 + .../android/eatery/data/repositories/EateryRepository.kt | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index ee46a886..9b33d8ef 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -43,6 +43,7 @@ class MainActivity : ComponentActivity() { override fun onResume(owner: LifecycleOwner) { super.onResume(owner) eateryRepository.pingEateries() + eateryRepository.pingLastEatery() } } lifecycle.addObserver(dataRefresher) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index 73650fb4..570a8a2a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -44,6 +44,8 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { */ val homeEateryFlow = _homeEateryFlow.asStateFlow() + private var lastEateryId: Int? = null + init { // Start loading backend as soon as the app initializes. pingEateries() @@ -69,6 +71,12 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { } } + fun pingLastEatery() { + if (lastEateryId != null) { + pingEatery(lastEateryId!!) + } + } + /** * Makes a new call to backend for all the abridged home eatery data. */ @@ -118,6 +126,7 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { * If ALL eateries are already loaded, then this simply instantly returns that. */ fun getEateryFlow(eateryId: Int): StateFlow> { + lastEateryId = eateryId if (eateryFlow.value is EateryApiResponse.Success) { return MutableStateFlow( EateryApiResponse.Success( From 7573887bd1898454f0448496a5814c4bc7464311 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 5 Oct 2025 01:30:19 -0400 Subject: [PATCH 033/126] Make getEateryFlow consume correct flow --- .../data/repositories/EateryRepository.kt | 17 ++--------------- .../eatery/ui/components/general/EateryCard.kt | 2 -- .../android/eatery/ui/screens/HomeScreen.kt | 9 --------- 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index 570a8a2a..7e497c21 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -127,22 +127,9 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { */ fun getEateryFlow(eateryId: Int): StateFlow> { lastEateryId = eateryId - if (eateryFlow.value is EateryApiResponse.Success) { - return MutableStateFlow( - EateryApiResponse.Success( - (eateryFlow.value as EateryApiResponse.Success>) - .data.find { it.id == eateryId }!! - ) - ) + if (!eateryApiCache.contains(eateryId)) { + pingEatery(eateryId) } - - // If not called yet or is in an error, re-ping. - if (!eateryApiCache.contains(eateryId) - || eateryApiCache[eateryId]!!.value is EateryApiResponse.Error - ) { - pingEatery(eateryId = eateryId) - } - return eateryApiCache[eateryId]!! } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt index 4954a4e6..ad6354ed 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt @@ -1,6 +1,5 @@ package com.cornellappdev.android.eatery.ui.components.general -import android.util.Log import androidx.compose.animation.Crossfade import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -118,7 +117,6 @@ fun EateryCard( backgroundColor = Color.White, modifier = modifier ) { - Log.d("TAG", "EateryCard:still alvie ") Column { Box { Crossfade( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 3c6f9595..29b7ec8d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -2,7 +2,6 @@ package com.cornellappdev.android.eatery.ui.screens import android.Manifest -import android.util.Log import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi @@ -473,10 +472,6 @@ private fun HomeScrollableMainContent( } } else { itemsIndexed(nearestEateries) { index, eatery -> - Log.d( - "TAG", - "HomeScrollableMainContent: index = $index, eatery = $eatery, \n\n\nsize = ${nearestEateries.size}" - ) Box( Modifier.padding( start = 16.dp, @@ -484,10 +479,6 @@ private fun HomeScrollableMainContent( top = if (index != 0) 12.dp else 0.dp ) ) { - Log.d( - "TAG", - "HomeScrollableMainContent: index = $index, eatery = $eatery" - ) EateryCard( eatery = eatery, isFavorite = favorites.any { favoriteEatery -> From 1b59dffb0683503fb0eb62717546a4347c2c02fc Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 5 Oct 2025 14:27:31 -0400 Subject: [PATCH 034/126] Change eatery cache to a map flow --- .../data/repositories/EateryRepository.kt | 29 +++++++++++-------- .../android/eatery/ui/screens/SearchScreen.kt | 25 ++++++++++------ .../ui/viewmodels/EateryDetailViewModel.kt | 4 +-- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index 7e497c21..67be53ee 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -7,9 +7,12 @@ import com.cornellappdev.android.eatery.data.models.Event import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -95,8 +98,8 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { /** * A map from eatery ids to the states representing their API loading calls. */ - private val eateryApiCache: MutableMap>> = - mutableMapOf() + private val eateryApiCache: MutableStateFlow>> = + MutableStateFlow(mutableMapOf()) /** * Makes a new call to backend for the specified eatery. After calling, @@ -105,31 +108,33 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { */ private fun pingEatery(eateryId: Int) { // If first time calling, make new state. - if (eateryApiCache[eateryId] == null) { - eateryApiCache[eateryId] = MutableStateFlow(EateryApiResponse.Pending) - } - - eateryApiCache[eateryId]!!.value = EateryApiResponse.Pending + updateCache(eateryId, EateryApiResponse.Pending) CoroutineScope(Dispatchers.IO).launch { try { val eatery = getEatery(eateryId = eateryId) - eateryApiCache[eateryId]!!.value = EateryApiResponse.Success(eatery) + updateCache(eateryId, EateryApiResponse.Success(eatery)) } catch (_: Exception) { - eateryApiCache[eateryId]!!.value = EateryApiResponse.Error + updateCache(eateryId, EateryApiResponse.Error) } } } + private fun updateCache(eateryId: Int, response: EateryApiResponse) { + eateryApiCache.update { + (it + (eateryId to response)).toMutableMap() + } + } + /** * Returns the [StateFlow] representing the API call for the specified eatery. * If ALL eateries are already loaded, then this simply instantly returns that. */ - fun getEateryFlow(eateryId: Int): StateFlow> { + fun getEateryFlow(eateryId: Int): Flow> { lastEateryId = eateryId - if (!eateryApiCache.contains(eateryId)) { + if (!eateryApiCache.value.contains(eateryId)) { pingEatery(eateryId) } - return eateryApiCache[eateryId]!! + return eateryApiCache.map { it[eateryId]!! } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt index 1c398401..fcf8d2bd 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt @@ -109,7 +109,8 @@ fun SearchScreen( } } - ModalBottomSheetLayout(sheetState = modalBottomSheetState, sheetShape = RoundedCornerShape( + ModalBottomSheetLayout( + sheetState = modalBottomSheetState, sheetShape = RoundedCornerShape( bottomStart = 0.dp, bottomEnd = 0.dp, topStart = 12.dp, topEnd = 12.dp ), sheetElevation = 8.dp, sheetContent = { PaymentMethodsBottomSheet(selectedFilters = selectedPaymentMethodFilters, hide = { @@ -242,7 +243,10 @@ fun SearchScreen( ) recentSearches.forEach { eateryId -> - val eateryResponse = searchViewModel.openEatery(eateryId).value + val eateryResponse = + searchViewModel.openEatery(eateryId).collectAsState( + initial = EateryApiResponse.Pending + ).value if (eateryResponse is EateryApiResponse.Success) { Box( Modifier.padding( @@ -280,7 +284,8 @@ fun SearchScreen( horizontal = 16.dp, vertical = 12.dp ) ) { - EateryCard(eatery = eatery, + EateryCard( + eatery = eatery, isFavorite = favorites.any { favoriteEatery -> favoriteEatery.id == eatery.id }, @@ -309,14 +314,16 @@ fun SearchScreen( fun FavoriteItem( eatery: Eatery, onEateryClick: (eatery: Eatery) -> Unit ) { - Column(modifier = Modifier - .width(96.dp) - .clickable { - onEateryClick(eatery) - }) { + Column( + modifier = Modifier + .width(96.dp) + .clickable { + onEateryClick(eatery) + }) { // Use box to overlay the star over the eatery Box { - GlideImage(imageModel = { eatery.imageUrl ?: "" }, + GlideImage( + imageModel = { eatery.imageUrl ?: "" }, modifier = Modifier .fillMaxWidth() .height(96.dp) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt index 7bf2c090..fffca36c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt @@ -13,8 +13,8 @@ import com.cornellappdev.android.eatery.ui.components.general.MenuItemViewState import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse import com.cornellappdev.android.eatery.util.fromOffsetToDayOfWeek import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn @@ -72,7 +72,7 @@ class EateryDetailViewModel @Inject constructor( /** * A flow emitting the loading status of the current eatery. */ - private val eateryFlow: StateFlow> = + private val eateryFlow: Flow> = eateryRepository.getEateryFlow(eateryId) private val userSelectedMeal = MutableStateFlow(null) From b32e5a8908576a640c264c83bfdfa0cbb926d9fb Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 6 Oct 2025 14:17:58 -0400 Subject: [PATCH 035/126] Update eateryApiCache when eateries are pinged. --- .../android/eatery/MainActivity.kt | 1 - .../data/repositories/EateryRepository.kt | 30 +++++++++---------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 9b33d8ef..ee46a886 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -43,7 +43,6 @@ class MainActivity : ComponentActivity() { override fun onResume(owner: LifecycleOwner) { super.onResume(owner) eateryRepository.pingEateries() - eateryRepository.pingLastEatery() } } lifecycle.addObserver(dataRefresher) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index 67be53ee..fc6b2f38 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -47,7 +47,11 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { */ val homeEateryFlow = _homeEateryFlow.asStateFlow() - private var lastEateryId: Int? = null + /** + * A map from eatery ids to the states representing their API loading calls. + */ + private val eateryApiCache: MutableStateFlow>> = + MutableStateFlow(mapOf>().withDefault { EateryApiResponse.Error }) init { // Start loading backend as soon as the app initializes. @@ -64,22 +68,23 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { */ private fun pingAllEateries() { _eateryFlow.value = EateryApiResponse.Pending + eateryApiCache.update { map -> map.mapValues { EateryApiResponse.Pending } } CoroutineScope(Dispatchers.IO).launch { try { val eateries = getAllEateries() _eateryFlow.value = EateryApiResponse.Success(eateries) + eateryApiCache.update { map -> + eateries.filter { it.id != null } + .associate { it.id!! to EateryApiResponse.Success(it) } + .withDefault { EateryApiResponse.Error } + } } catch (_: Exception) { _eateryFlow.value = EateryApiResponse.Error + eateryApiCache.update { map -> map.mapValues { EateryApiResponse.Error } } } } } - fun pingLastEatery() { - if (lastEateryId != null) { - pingEatery(lastEateryId!!) - } - } - /** * Makes a new call to backend for all the abridged home eatery data. */ @@ -95,12 +100,6 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { } } - /** - * A map from eatery ids to the states representing their API loading calls. - */ - private val eateryApiCache: MutableStateFlow>> = - MutableStateFlow(mutableMapOf()) - /** * Makes a new call to backend for the specified eatery. After calling, * `eateryApiCache[eateryId]` is guaranteed to contain a state actively loading that eatery's @@ -122,7 +121,7 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { private fun updateCache(eateryId: Int, response: EateryApiResponse) { eateryApiCache.update { - (it + (eateryId to response)).toMutableMap() + (it + (eateryId to response)).withDefault { EateryApiResponse.Error } } } @@ -131,10 +130,9 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { * If ALL eateries are already loaded, then this simply instantly returns that. */ fun getEateryFlow(eateryId: Int): Flow> { - lastEateryId = eateryId if (!eateryApiCache.value.contains(eateryId)) { pingEatery(eateryId) } - return eateryApiCache.map { it[eateryId]!! } + return eateryApiCache.map { it.getValue(eateryId) } } } From acef85f6d56c19a632dd7a4d810b03c27ed5dfb1 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 6 Oct 2025 14:21:55 -0400 Subject: [PATCH 036/126] Use withDefault for cache --- .../eatery/data/repositories/EateryRepository.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index fc6b2f38..6a9b4d8c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -68,7 +68,10 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { */ private fun pingAllEateries() { _eateryFlow.value = EateryApiResponse.Pending - eateryApiCache.update { map -> map.mapValues { EateryApiResponse.Pending } } + eateryApiCache.update { map -> + map.mapValues { EateryApiResponse.Pending } + .withDefault { EateryApiResponse.Error } + } CoroutineScope(Dispatchers.IO).launch { try { val eateries = getAllEateries() @@ -80,7 +83,10 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { } } catch (_: Exception) { _eateryFlow.value = EateryApiResponse.Error - eateryApiCache.update { map -> map.mapValues { EateryApiResponse.Error } } + eateryApiCache.update { map -> + map.mapValues { EateryApiResponse.Error } + .withDefault { EateryApiResponse.Error } + } } } } From eb82d00bab4e4b2ae51893ad014967036e36fc97 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 6 Oct 2025 15:54:15 -0400 Subject: [PATCH 037/126] Set cache to empty map at error response --- .../android/eatery/data/repositories/EateryRepository.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index 6a9b4d8c..05c0e620 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -83,9 +83,8 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { } } catch (_: Exception) { _eateryFlow.value = EateryApiResponse.Error - eateryApiCache.update { map -> - map.mapValues { EateryApiResponse.Error } - .withDefault { EateryApiResponse.Error } + eateryApiCache.update { + emptyMap>().withDefault { EateryApiResponse.Error } } } } From a885feb814ca3133967df9fae1ce069c26278285 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 15 Oct 2025 17:10:29 -0400 Subject: [PATCH 038/126] Initial login refactor --- .../android/eatery/data/NetworkingApi.kt | 24 +++++++++++++++++-- .../android/eatery/data/models/User.kt | 10 ++++++++ .../repositories/UserPreferencesRepository.kt | 13 ++++++++++ .../data/repositories/UserRepository.kt | 21 ++++++++-------- .../eatery/ui/viewmodels/LoginViewModel.kt | 21 ++++++++++++---- app/src/main/proto/user_prefs.proto | 2 ++ 6 files changed, 73 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index ad231561..195a4932 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -1,15 +1,29 @@ package com.cornellappdev.android.eatery.data import com.cornellappdev.android.eatery.BuildConfig -import com.cornellappdev.android.eatery.data.models.* +import com.cornellappdev.android.eatery.data.models.AccountsResponse +import com.cornellappdev.android.eatery.data.models.ApiResponse +import com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.Event +import com.cornellappdev.android.eatery.data.models.GetApiAccountsParams +import com.cornellappdev.android.eatery.data.models.GetApiRequestBody +import com.cornellappdev.android.eatery.data.models.GetApiResponse +import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryParams +import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryQueryCriteria +import com.cornellappdev.android.eatery.data.models.GetApiUserParams +import com.cornellappdev.android.eatery.data.models.ReportSendBody +import com.cornellappdev.android.eatery.data.models.TransactionsResponse +import com.cornellappdev.android.eatery.data.models.User import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Url import java.text.SimpleDateFormat import java.time.Duration -import java.util.* +import java.util.Date +import java.util.Locale interface NetworkApi { @POST() @@ -47,6 +61,12 @@ interface NetworkApi { suspend fun sendReport( @Body report: ReportSendBody ): GetApiResponse + + @POST("/user/authorize") + suspend fun authorizeUser( + @Header("Authorization") sessionId: String, + @Body user: User + ): ApiResponse } fun generateUserBody(sessionId: String): GetApiRequestBody { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index 697dab21..8e00d893 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -6,6 +6,16 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class User( @Json(name = "id") val id: String? = null, + @Json(name = "fcmToken") val fcmToken: String? = null, + @Json(name = "deviceId") val deviceId: String? = null, + @Json(name = "pin") val pin: Int? = null, + @Json(name = "favorite_eateries") val favoriteEateries: List? = null, + @Json(name = "brb_balance") val brbBalance: Double? = null, + @Json(name = "city_bucks_balance") val cityBucksBalance: Double? = null, + @Json(name = "laundry_balance") val laundryBalance: Double? = null, + @Json(name = "brb_account_name") val brbAccountName: String? = null, + @Json(name = "city_bucks_account_name") val cityBucksAccountName: String? = null, + @Json(name = "laundry_account_name") val laundryAccountName: String? = null, @Json(name = "userName") val userName: String? = null, @Json(name = "firstName") val firstName: String? = null, @Json(name = "middleName") val middleName: String? = null, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 87ecbb7d..52ff8bce 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -127,4 +127,17 @@ class UserPreferencesRepository @Inject constructor( suspend fun fetchLoginInfo(): Pair = Pair(userPreferencesFlow.first().username, userPreferencesFlow.first().password) + + suspend fun setDeviceId(deviceId: java.util.UUID) { + userPreferencesStore.updateData { currentPreferences -> + currentPreferences.toBuilder() + .setDeviceId(deviceId.toString()) + .build() + } + } + + suspend fun getDeviceId(): String? { + val id: String? = userPreferencesFlow.first().deviceId + return if (id.isNullOrEmpty()) null else id + } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 881b3a0f..5862f0e9 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -3,12 +3,12 @@ package com.cornellappdev.android.eatery.data.repositories import com.cornellappdev.android.eatery.BuildConfig import com.cornellappdev.android.eatery.data.NetworkApi import com.cornellappdev.android.eatery.data.models.AccountsResponse +import com.cornellappdev.android.eatery.data.models.ApiResponse import com.cornellappdev.android.eatery.data.models.GetApiAccountsParams import com.cornellappdev.android.eatery.data.models.GetApiRequestBody import com.cornellappdev.android.eatery.data.models.GetApiResponse import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryParams import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryQueryCriteria -import com.cornellappdev.android.eatery.data.models.GetApiUserParams import com.cornellappdev.android.eatery.data.models.ReportSendBody import com.cornellappdev.android.eatery.data.models.TransactionsResponse import com.cornellappdev.android.eatery.data.models.User @@ -29,17 +29,16 @@ class UserRepository @Inject constructor(private val networkApi: NetworkApi) { ) ) - suspend fun getUser(sessionId: String): GetApiResponse = - networkApi.fetchUser( - url = BuildConfig.GET_BACKEND_URL + "user", - body = GetApiRequestBody( - version = "1", - method = "retrieve", - params = GetApiUserParams( - sessionId = sessionId - ) - ) + suspend fun getUser( + sessionId: String, + deviceId: String, + fcmToken: String + ): ApiResponse { + return networkApi.authorizeUser( + sessionId = "Bearer $sessionId", + user = User(deviceId = deviceId, pin = 1234, fcmToken = fcmToken) ) + } suspend fun getAccount(sessionId: String, userId: String): GetApiResponse = networkApi.fetchAccounts( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index fed69b63..b9c393e5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -13,8 +13,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.util.UUID import javax.inject.Inject @HiltViewModel @@ -65,6 +67,10 @@ class LoginViewModel @Inject constructor( AccountType.OFF_CAMPUS ) + init { + getSavedLoginInfo() + } + fun resetLogin() { _state.value = State.Login() } @@ -137,10 +143,6 @@ class LoginViewModel @Inject constructor( } } - init { - getSavedLoginInfo() - } - private fun getSavedLoginInfo() = viewModelScope.launch { if (userPreferencesRepository.getIsLoggedIn()) { val loginInfo = userPreferencesRepository.fetchLoginInfo() @@ -152,10 +154,19 @@ class LoginViewModel @Inject constructor( getUser(sessionId) } + /** + * Fetches user data given [sessionId] and updates the state and user preferences. + */ private fun getUser(sessionId: String) = viewModelScope.launch { try { val currState = _state.value - val user = userRepository.getUser(sessionId).response!! + if (userPreferencesRepository.getDeviceId() == null) { + userPreferencesRepository.setDeviceId(UUID.randomUUID()) + } + val fcmToken = + com.google.firebase.messaging.FirebaseMessaging.getInstance().token.await() + val deviceId = userPreferencesRepository.getDeviceId()!! + val user = userRepository.getUser(sessionId, deviceId, fcmToken).data!! val account = userRepository.getAccount(sessionId, user.id!!).response!!.accounts val transactions = userRepository.getTransactionHistory(sessionId, user.id).response!!.transactions diff --git a/app/src/main/proto/user_prefs.proto b/app/src/main/proto/user_prefs.proto index b5b4cc6b..4ac3736d 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -27,6 +27,8 @@ message UserPreferences { map itemFavorites = 11; + string deviceId = 12; + // repeated int32 recentSearches = 2; // string username = 3; // // Must be encrypted / decrypted. From 3d2b1a31a526e6b5460c08131499ba7a6b339386 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 26 Oct 2025 15:58:27 -0400 Subject: [PATCH 039/126] Add error states in Home and Upcoming Menus --- .../android/eatery/ui/screens/HomeScreen.kt | 532 +++++++++++------- .../eatery/ui/screens/UpcomingMenuScreen.kt | 314 ++++++----- .../eatery/ui/viewmodels/HomeViewModel.kt | 23 +- .../eatery/ui/viewmodels/UpcomingViewModel.kt | 34 +- 4 files changed, 544 insertions(+), 359 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 3c6f9595..b82ef38e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -2,7 +2,6 @@ package com.cornellappdev.android.eatery.ui.screens import android.Manifest -import android.util.Log import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi @@ -13,14 +12,19 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -38,6 +42,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -50,8 +56,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -59,7 +67,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle 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.em import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.android.eatery.BuildConfig @@ -83,11 +93,13 @@ import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.viewmodels.HomeViewModel import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.util.EateryPreview import com.cornellappdev.android.eatery.util.LocationHandler import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @OptIn( @@ -109,7 +121,6 @@ fun HomeScreen( val nearestEateries = homeViewModel.eateriesByDistance.collectAsState().value val eateriesApiResponse = homeViewModel.eateryFlow.collectAsState().value val filters = homeViewModel.filtersFlow.collectAsState().value - val notificationPermissionState = rememberMultiplePermissionsState( permissions = listOf( @@ -118,12 +129,10 @@ fun HomeScreen( ) ) - LaunchedEffect(notificationPermissionState.allPermissionsGranted) { LocationHandler.instantiate(context) } - val selectedPaymentMethodFilters = remember { mutableStateListOf() } val modalBottomSheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, @@ -131,7 +140,6 @@ fun HomeScreen( ) val coroutineScope = rememberCoroutineScope() - // Here a DisposableEffect is launched when the bottom sheet opens. // When it disappears it's from the view hierarchy, which will cause // onDispose to be called, adding/resetting the payment filters. @@ -184,7 +192,6 @@ fun HomeScreen( }, floatingActionButtonPosition = FabPosition.End, content = { paddingValues -> - Box( modifier = Modifier .background(Color.White) @@ -199,44 +206,18 @@ fun HomeScreen( topEnd = 12.dp ), sheetElevation = 8.dp, - sheetContent = { - when (sheetContent) { - BottomSheetContent.PAYMENT_METHODS_AVAILABLE -> { - PaymentMethodsBottomSheet( - selectedFilters = selectedPaymentMethodFilters, - hide = { - coroutineScope.launch { - modalBottomSheetState.hide() - } - } - ) - } - - BottomSheetContent.COMPARE_MENUS -> { - CompareMenusBotSheet( - onDismiss = { - coroutineScope.launch { - modalBottomSheetState.hide() - } - }, - onCompareMenusClick = { selectedEateriesIds -> - coroutineScope.launch { - modalBottomSheetState.hide() - } - onCompareMenusClick(selectedEateriesIds) - } - ) - } - - else -> {} - } - }, + sheetContent = SheetContent( + sheetContent, + selectedPaymentMethodFilters, + coroutineScope, + modalBottomSheetState, + onCompareMenusClick + ), content = { HomeScrollableMainContent( onSearchClick = onSearchClick, onEateryClick = onEateryClick, onFavoriteExpand = onFavoriteExpand, - modalBottomSheetState = modalBottomSheetState, eateriesApiResponse = eateriesApiResponse, favorites = favorites, nearestEateries = nearestEateries, @@ -249,20 +230,22 @@ fun HomeScreen( } }, onFilterClicked = { filter -> - homeViewModel.toggleFilter(filter) + homeViewModel.toggleFilter( + filter = filter, + pingAgain = isErrorState(eateriesApiResponse) + ) }, onResetFilters = { - homeViewModel.resetFilters() + homeViewModel.resetFilters( + pingAgain = isErrorState(eateriesApiResponse) + ) }, filters = homeViewModel.homeScreenFilters, isGridView = isGridView, - onListClick = { - isGridView = false - }, - onGridClick = { - isGridView = true - }, - onNotificationsClick = onNotificationsClick + onListClick = { isGridView = false }, + onGridClick = { isGridView = true }, + onNotificationsClick = onNotificationsClick, + onReload = homeViewModel::pingEateries ) } ) @@ -280,6 +263,47 @@ fun HomeScreen( }) } +@Composable +@OptIn(ExperimentalMaterialApi::class) +private fun SheetContent( + sheetContent: BottomSheetContent, + selectedPaymentMethodFilters: SnapshotStateList, + coroutineScope: CoroutineScope, + modalBottomSheetState: ModalBottomSheetState, + onCompareMenusClick: (List) -> Unit +): @Composable ColumnScope.() -> Unit = { + when (sheetContent) { + BottomSheetContent.PAYMENT_METHODS_AVAILABLE -> { + PaymentMethodsBottomSheet( + selectedFilters = selectedPaymentMethodFilters, + hide = { + coroutineScope.launch { + modalBottomSheetState.hide() + } + } + ) + } + + BottomSheetContent.COMPARE_MENUS -> { + CompareMenusBotSheet( + onDismiss = { + coroutineScope.launch { + modalBottomSheetState.hide() + } + }, + onCompareMenusClick = { selectedEateriesIds -> + coroutineScope.launch { + modalBottomSheetState.hide() + } + onCompareMenusClick(selectedEateriesIds) + } + ) + } + + else -> {} + } +} + @OptIn( ExperimentalFoundationApi::class, ExperimentalMaterialApi::class, @@ -292,7 +316,6 @@ private fun HomeScrollableMainContent( onFavoriteClick: (Eatery, Boolean) -> Unit, onFilterClicked: (Filter) -> Unit, onResetFilters: () -> Unit, - modalBottomSheetState: ModalBottomSheetState, eateriesApiResponse: EateryApiResponse>, nearestEateries: List, favorites: List, @@ -301,10 +324,10 @@ private fun HomeScrollableMainContent( isGridView: Boolean, onListClick: () -> Unit, onGridClick: () -> Unit, - onNotificationsClick: () -> Unit + onNotificationsClick: () -> Unit, + onReload: () -> Unit ) { val listState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() val isFirstVisible = remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } @@ -314,7 +337,26 @@ private fun HomeScrollableMainContent( if (favorites.isNotEmpty()) { lastFavorite = favorites[0] } - + if (isErrorState(eateriesApiResponse)) { + Column(modifier = Modifier.fillMaxSize()) { + HomeStickyHeader( + collapsed = isFirstVisible.value, + loaded = false, + onSearchClick = onSearchClick, + onNotificationsClick = onNotificationsClick + ) + HomeMainHeader( + onSearchClick = onSearchClick, + selectedFilters = selectedFilters, + filters = filters, + onFilterClicked = onFilterClicked + ) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + ErrorContent(onTryAgain = onReload) + } + } + return + } LazyColumn( state = listState, modifier = Modifier @@ -328,7 +370,14 @@ private fun HomeScrollableMainContent( onNotificationsClick = onNotificationsClick ) } - + item { + HomeMainHeader( + onSearchClick = onSearchClick, + selectedFilters = selectedFilters, + onFilterClicked = onFilterClicked, + filters = filters, + ) + } when (eateriesApiResponse) { is EateryApiResponse.Pending -> { items(MainLoadingItem.mainItems) { item -> @@ -336,171 +385,239 @@ private fun HomeScrollableMainContent( } } - is EateryApiResponse.Error -> { - // TODO: Add No Internet State + is EateryApiResponse.Success -> { + regularContent( + eateriesApiResponse, + selectedFilters, + favorites, + onFavoriteClick, + onEateryClick, + onResetFilters, + lastFavorite, + onFavoriteExpand, + isGridView, + onListClick, + onGridClick, + nearestEateries + ) } - is EateryApiResponse.Success -> { - val eateries = eateriesApiResponse.data - - item { - HomeMainHeader( - onSearchClick = onSearchClick, - selectedFilters = selectedFilters, - onFilterClicked = onFilterClicked, - onPaymentMethodsClicked = { - coroutineScope.launch { - modalBottomSheetState.show() - } - }, - filters = filters, - ) - } + EateryApiResponse.Error -> { + // impossible + } + } + } +} - if (selectedFilters.isNotEmpty()) { - if (eateries.isNotEmpty()) { - items(eateries) { eatery -> - Box( - Modifier.padding( - horizontal = 16.dp, - vertical = 12.dp - ) - ) { - EateryCard( - eatery = eatery, - isFavorite = favorites.any { favoriteEatery -> - favoriteEatery.id == eatery.id - }, - onFavoriteClick = { - onFavoriteClick(eatery, it) - } - ) { - onEateryClick(it) - } - } - } - } else { - item { - Box( - modifier = Modifier - .fillParentMaxHeight(0.7f) - .fillMaxWidth() - ) { - NoEateryFound(modifier = Modifier.align(Alignment.Center)) { - onResetFilters() - } - } +private fun isErrorState(eateriesApiResponse: EateryApiResponse>): Boolean = + eateriesApiResponse is EateryApiResponse.Error + +@Composable +fun ErrorContent(onTryAgain: () -> Unit) { + Column( + modifier = Modifier + .width(293.dp) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_error), + contentDescription = "Error Icon", + modifier = Modifier.size(72.dp), + tint = Color.Red + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Hmm, no chow here (yet).", + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF1B1F23), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "We ran into an issue loading this page. Check your connection or try reloading the page.", + fontSize = 18.sp, + fontWeight = FontWeight.Normal, + color = Color(0xFF1B1F23), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onTryAgain, + modifier = Modifier + .width(109.dp) + .height(34.dp) + .clip(RoundedCornerShape(17.dp)), + colors = ButtonDefaults.buttonColors(containerColor = EateryBlue) + ) { + Text( + text = "Try Again", color = Color.White, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + lineHeight = 1.25.em + ) + } + } +} + +@Preview +@Composable +private fun PreviewErrorContent() = EateryPreview { + ErrorContent(onTryAgain = {}) +} + +@OptIn(ExperimentalMaterialApi::class) +private fun LazyListScope.regularContent( + eateriesApiResponse: EateryApiResponse.Success>, + selectedFilters: List, + favorites: List, + onFavoriteClick: (Eatery, Boolean) -> Unit, + onEateryClick: (Eatery) -> Unit, + onResetFilters: () -> Unit, + lastFavorite: Eatery?, + onFavoriteExpand: () -> Unit, + isGridView: Boolean, + onListClick: () -> Unit, + onGridClick: () -> Unit, + nearestEateries: List +) { + val eateries = eateriesApiResponse.data + + if (selectedFilters.isNotEmpty()) { + if (eateries.isNotEmpty()) { + items(eateries) { eatery -> + Box( + Modifier.padding( + horizontal = 16.dp, + vertical = 12.dp + ) + ) { + EateryCard( + eatery = eatery, + isFavorite = favorites.any { favoriteEatery -> + favoriteEatery.id == eatery.id + }, + onFavoriteClick = { + onFavoriteClick(eatery, it) } + ) { + onEateryClick(it) } - } else { - item { - Spacer(modifier = Modifier.height(6.dp)) - val showFake = favorites.isEmpty() && lastFavorite != null - - EateryHomeSection( - title = "Favorites", - eateries = favorites, - overflowEatery = if (showFake) lastFavorite else null, - onEateryClick = onEateryClick, - onFavoriteClick = onFavoriteClick, - onExpandClick = onFavoriteExpand, - favoritesDecider = { !showFake } - ) + } + } + } else { + item { + Box( + modifier = Modifier + .fillParentMaxHeight(0.7f) + .fillMaxWidth() + ) { + NoEateryFound(modifier = Modifier.align(Alignment.Center)) { + onResetFilters() } + } + } + } + } else { + item { + Spacer(modifier = Modifier.height(6.dp)) + val showFake = favorites.isEmpty() && lastFavorite != null + + EateryHomeSection( + title = "Favorites", + eateries = favorites, + overflowEatery = if (showFake) lastFavorite else null, + onEateryClick = onEateryClick, + onFavoriteClick = onFavoriteClick, + onExpandClick = onFavoriteExpand, + favoritesDecider = { !showFake } + ) + } + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .padding(start = 16.dp, bottom = 12.dp), + text = "All Eateries", + style = EateryBlueTypography.h4, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .padding(end = 12.dp) + ) { + Icon( + painter = painterResource(id = if (isGridView) R.drawable.ic_list_view_unselected else R.drawable.ic_list_view_selected), + contentDescription = "List View", + tint = Color.Unspecified, + modifier = Modifier.clickable { onListClick() } + ) + Icon( + painter = painterResource(id = if (isGridView) R.drawable.ic_grid_view_selected else R.drawable.ic_grid_view_unselected), + contentDescription = "Grid View", + tint = Color.Unspecified, + modifier = Modifier.clickable { onGridClick() } + ) + } - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() + } + } + if (isGridView) { + items(eateries.chunked(2)) { row -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + row.forEach { eatery -> + Box( + modifier = Modifier + .weight(1f) + .padding(bottom = 12.dp) ) { - Text( - modifier = Modifier - .padding(start = 16.dp, bottom = 12.dp), - text = "All Eateries", - style = EateryBlueTypography.h4, + EateryCard( + eatery = eatery, + isFavorite = favorites.any { it.id == eatery.id }, + onFavoriteClick = { isFavorite -> + onFavoriteClick(eatery, isFavorite) + }, + style = EateryCardStyle.GRID_VIEW, + selectEatery = { selectedEatery -> + onEateryClick(selectedEatery) + } ) - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier - .padding(end = 12.dp) - ) { - Icon( - painter = painterResource(id = if (isGridView) R.drawable.ic_list_view_unselected else R.drawable.ic_list_view_selected), - contentDescription = "List View", - tint = Color.Unspecified, - modifier = Modifier.clickable { onListClick() } - ) - Icon( - painter = painterResource(id = if (isGridView) R.drawable.ic_grid_view_selected else R.drawable.ic_grid_view_unselected), - contentDescription = "Grid View", - tint = Color.Unspecified, - modifier = Modifier.clickable { onGridClick() } - ) - } } } - if (isGridView) { - items(eateries.chunked(2)) { row -> - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - row.forEach { eatery -> - Box( - modifier = Modifier - .weight(1f) - .padding(bottom = 12.dp) - ) { - EateryCard( - eatery = eatery, - isFavorite = favorites.any { it.id == eatery.id }, - onFavoriteClick = { isFavorite -> - onFavoriteClick(eatery, isFavorite) - }, - style = EateryCardStyle.GRID_VIEW, - selectEatery = { selectedEatery -> - onEateryClick(selectedEatery) - } - ) - - } - } - } - } - } else { - itemsIndexed(nearestEateries) { index, eatery -> - Log.d( - "TAG", - "HomeScrollableMainContent: index = $index, eatery = $eatery, \n\n\nsize = ${nearestEateries.size}" - ) - Box( - Modifier.padding( - start = 16.dp, - end = 16.dp, - top = if (index != 0) 12.dp else 0.dp - ) - ) { - Log.d( - "TAG", - "HomeScrollableMainContent: index = $index, eatery = $eatery" - ) - EateryCard( - eatery = eatery, - isFavorite = favorites.any { favoriteEatery -> - favoriteEatery.id == eatery.id - }, - onFavoriteClick = { - onFavoriteClick(eatery, it) - } - ) { - onEateryClick(it) - } - } + } + } + } else { + itemsIndexed(nearestEateries) { index, eatery -> + Box( + Modifier.padding( + start = 16.dp, + end = 16.dp, + top = if (index != 0) 12.dp else 0.dp + ) + ) { + EateryCard( + eatery = eatery, + isFavorite = favorites.any { favoriteEatery -> + favoriteEatery.id == eatery.id + }, + onFavoriteClick = { + onFavoriteClick(eatery, it) } + ) { + onEateryClick(it) } } } @@ -595,21 +712,18 @@ private fun HomeStickyHeader( ) } } - } } } } } - @Composable private fun HomeMainHeader( onSearchClick: () -> Unit, selectedFilters: List, filters: List, onFilterClicked: (Filter) -> Unit, - onPaymentMethodsClicked: () -> Unit, ) { SearchBar( searchText = "", @@ -629,7 +743,7 @@ private fun HomeMainHeader( FilterRow( currentFiltersSelected = selectedFilters, onFilterClicked = onFilterClicked, - filters = filters, + filters = filters ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt index 47836f65..4d6d7e0a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember @@ -48,6 +49,7 @@ import com.cornellappdev.android.eatery.ui.components.upcoming.UpcomingLoadingIt import com.cornellappdev.android.eatery.ui.components.upcoming.UpcomingLoadingItem.Companion.CreateUpcomingLoadingItem import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography +import com.cornellappdev.android.eatery.ui.viewmodels.UpcomingMenusViewState import com.cornellappdev.android.eatery.ui.viewmodels.UpcomingViewModel import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse import com.cornellappdev.android.eatery.util.AppStorePopupRepository @@ -95,7 +97,10 @@ fun UpcomingMenuScreen( sheetState = modalBottomSheetState, selectedMeal = viewState.mealFilter, onSubmit = { - upcomingViewModel.changeMealFilter(it) + upcomingViewModel.changeMealFilter( + filter = it, + pingAgain = isErrorState(viewState) + ) }, onReset = { upcomingViewModel.resetMealFilter() @@ -107,158 +112,147 @@ fun UpcomingMenuScreen( } ) }, - content = { -> + content = { val innerListState = rememberLazyListState() val isFirstVisible = remember { derivedStateOf { innerListState.firstVisibleItemIndex > 0 } } - LazyColumn( - state = innerListState, modifier = Modifier.fillMaxSize() - ) { - - stickyHeader { - Column( - modifier = Modifier - .fillMaxWidth() - .background(EateryBlue) - .then(Modifier.statusBarsPadding()) - .padding(bottom = 7.dp), - ) { - AnimatedContent( - targetState = isFirstVisible.value - ) { isFirstVisible -> - if (isFirstVisible) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - ) { - Text( - modifier = Modifier.align(Alignment.Center), - textAlign = TextAlign.Center, - text = "Upcoming Menus", - color = White, - style = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 20.sp - ) - ) - } - } else { - Column( - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 56.dp - ) - ) { - Text( - text = "Upcoming Menus", - color = White, - style = EateryBlueTypography.h2 - ) - } - } - } - } + val upcomingMenuHeader = @Composable { + UpcomingMenuHeader(isFirstVisible) + } + val calendarWeekSelector = @Composable { + Box( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) + ) { + CalendarWeekSelector( + dayNames = (0 until 7).map { + LocalDate.now().plusDays(it.toLong()) + .format(DateTimeFormatter.ofPattern("EEE")) + }, + currSelectedDay = viewState.selectedDay, + selectedDay = viewState.selectedDay, + days = (0 until 7).map { + LocalDate.now().plusDays(it.toLong()).dayOfMonth + }, + onClick = { i -> + upcomingViewModel.selectDayOffset( + offset = i, + pingAgain = isErrorState(viewState) + ) + }, + closedDays = null + ) } + } + val filterRow = @Composable { + FilterRow( + customItemsBefore = { + item { + FilterButton( + onFilterClicked = { + coroutineScope.launch { + modalBottomSheetState.show() + } + }, + selected = true, + text = when (viewState.mealFilter) { + MealFilter.LATE_DINNER -> "Late Dinner" + else -> viewState.mealFilter.text.first() + }, + icon = Icons.Default.ExpandMore + ) + } + }, + filters = upcomingViewModel.upcomingMenuFilters, + currentFiltersSelected = viewState.selectedFilters, + onFilterClicked = { filter -> + upcomingViewModel.toggleFilter( + filter = filter, + pingAgain = isErrorState(viewState) + ) + }, + ) + } - item { + if (isErrorState(viewState)) { + Column(modifier = Modifier.fillMaxSize()) { + upcomingMenuHeader() + calendarWeekSelector() + filterRow() Box( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - CalendarWeekSelector( - dayNames = (0 until 7).map { - LocalDate.now().plusDays(it.toLong()) - .format(DateTimeFormatter.ofPattern("EEE")) - }, - currSelectedDay = viewState.selectedDay, - selectedDay = viewState.selectedDay, - days = (0 until 7).map { - LocalDate.now().plusDays(it.toLong()).dayOfMonth - }, - onClick = { i -> upcomingViewModel.selectDayOffset(i) }, - closedDays = null - ) + ErrorContent(onTryAgain = upcomingViewModel::pingEateries) } } - item { - FilterRow( - customItemsBefore = { - item { - FilterButton( - onFilterClicked = { - coroutineScope.launch { - modalBottomSheetState.show() - } - }, - selected = true, - text = when (viewState.mealFilter) { - MealFilter.LATE_DINNER -> "Late Dinner" - else -> viewState.mealFilter.text.first() - }, - icon = Icons.Default.ExpandMore + } else { + LazyColumn( + state = innerListState, modifier = Modifier.fillMaxSize() + ) { + stickyHeader { + upcomingMenuHeader() + } + item { + calendarWeekSelector() + } + item { + filterRow() + } + when (val menus = viewState.menus) { + is EateryApiResponse.Pending -> { + items(UpcomingLoadingItem.upcomingItems) { item -> + CreateUpcomingLoadingItem( + item, + shimmer ) } - }, - filters = upcomingViewModel.upcomingMenuFilters, - currentFiltersSelected = viewState.selectedFilters, - onFilterClicked = upcomingViewModel::toggleFilter, - ) - } - when (val menus = viewState.menus) { - is EateryApiResponse.Pending -> { - items(UpcomingLoadingItem.upcomingItems) { item -> - CreateUpcomingLoadingItem( - item, - shimmer - ) } - } - is EateryApiResponse.Error -> { - item { Text(text = "error") } - } - - is EateryApiResponse.Success -> { - if (menus.data.isEmpty()) { - item { - Box( - modifier = Modifier - .fillParentMaxHeight(0.7f) - .fillMaxWidth() - ) { - NoEateryFound( - modifier = Modifier.align( - Alignment.Center - ), resetFilters = { - upcomingViewModel.resetFilters() - }) + is EateryApiResponse.Success -> { + if (menus.data.isEmpty()) { + item { + Box( + modifier = Modifier + .fillParentMaxHeight(0.7f) + .fillMaxWidth() + ) { + NoEateryFound( + modifier = Modifier.align( + Alignment.Center + ), resetFilters = { + upcomingViewModel.resetFilters(pingAgain = false) + }) + } + Spacer(modifier = Modifier.height(12.dp)) } - Spacer(modifier = Modifier.height(12.dp)) } - } - items(menus.data) { - Column(modifier = Modifier.padding(horizontal = 12.dp)) { - Text( - modifier = Modifier.padding(start = 6.dp), - text = it.header, - style = EateryBlueTypography.h4 - ) - Spacer(modifier = Modifier.height(16.dp)) - it.menuCards.forEach { eatery -> - MenuCard( - eatery, - selectEatery = { - onEateryClick(eatery.eateryId) - }, - onEateryCardContract = { - appStorePopupRepository.requestRatingPopup() - } + items(menus.data) { + Column(modifier = Modifier.padding(horizontal = 12.dp)) { + Text( + modifier = Modifier.padding(start = 6.dp), + text = it.header, + style = EateryBlueTypography.h4 ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) + it.menuCards.forEach { eatery -> + MenuCard( + eatery, + selectEatery = { + onEateryClick(eatery.eateryId) + }, + onEateryCardContract = { + appStorePopupRepository.requestRatingPopup() + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } } } } + + EateryApiResponse.Error -> { + /* Handled above */ + } } } } @@ -266,3 +260,55 @@ fun UpcomingMenuScreen( ) } } + +private fun isErrorState(viewState: UpcomingMenusViewState): Boolean = + viewState.menus is EateryApiResponse.Error + +@Composable +@OptIn(ExperimentalAnimationApi::class) +private fun UpcomingMenuHeader(isFirstVisible: State) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(EateryBlue) + .then(Modifier.statusBarsPadding()) + .padding(bottom = 7.dp), + ) { + AnimatedContent( + targetState = isFirstVisible.value + ) { isFirstVisible -> + if (isFirstVisible) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + textAlign = TextAlign.Center, + text = "Upcoming Menus", + color = White, + style = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp + ) + ) + } + } else { + Column( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 56.dp + ) + ) { + Text( + text = "Upcoming Menus", + color = White, + style = EateryBlueTypography.h2 + ) + } + } + } + } +} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index b77e8bff..61b4380e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -125,7 +125,10 @@ class HomeViewModel @Inject constructor( bigPopUp = bool } - fun toggleFilter(filter: Filter) { + fun toggleFilter(filter: Filter, pingAgain: Boolean) { + if (pingAgain) { + eateryRepository.pingEateries() + } _filtersFlow.update { it.updateFilters(filter) } @@ -138,15 +141,15 @@ class HomeViewModel @Inject constructor( _filtersFlow.value = newList } - fun resetFilters() = viewModelScope.launch { - _filtersFlow.value = listOf() + fun resetFilters(pingAgain: Boolean) = { + if (pingAgain) { + eateryRepository.pingEateries() + } + viewModelScope.launch { + _filtersFlow.value = listOf() + } } - /** - * Determines if the eatery passes the filter inspection based on what's currently selected. - */ - - fun addFavorite(eateryId: Int?) { if (eateryId != null) userPreferencesRepository.setFavorite(eateryId, true) @@ -164,4 +167,8 @@ class HomeViewModel @Inject constructor( fun setNotificationFlowCompleted(value: Boolean) = viewModelScope.launch { userPreferencesRepository.setNotificationFlowCompleted(value) } + + fun pingEateries() { + eateryRepository.pingEateries() + } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt index b090dc68..a5f73634 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt @@ -45,7 +45,7 @@ data class EateriesSection( @HiltViewModel class UpcomingViewModel @Inject constructor( userPreferencesRepository: UserPreferencesRepository, - eateryRepository: EateryRepository + private val eateryRepository: EateryRepository ) : ViewModel() { private val mealFilterFlow = MutableStateFlow(nextMeal() ?: MealFilter.LATE_DINNER) @@ -119,14 +119,16 @@ class UpcomingViewModel @Inject constructor( menus = EateryApiResponse.Error, mealFilter = mealFilter, selectedFilters = filters, + selectedDay = selectedDayOffset ) } is EateryApiResponse.Pending -> { UpcomingMenusViewState( menus = EateryApiResponse.Pending, - mealFilter, - filters, + mealFilter = mealFilter, + selectedFilters = filters, + selectedDay = selectedDayOffset ) } @@ -163,18 +165,27 @@ class UpcomingViewModel @Inject constructor( UpcomingMenusViewState(mealFilter = nextMeal() ?: MealFilter.LATE_DINNER) ) - fun toggleFilter(filter: Filter) { + fun toggleFilter(filter: Filter, pingAgain: Boolean) { + if (pingAgain) { + eateryRepository.pingEateries() + } selectedFiltersFlow.update { it.updateFilters(filter) } } - fun resetFilters() { + fun resetFilters(pingAgain: Boolean) { + if (pingAgain) { + eateryRepository.pingEateries() + } resetMealFilter() selectedFiltersFlow.update { emptyList() } } - fun changeMealFilter(filter: MealFilter) { + fun changeMealFilter(filter: MealFilter, pingAgain: Boolean) { + if (pingAgain) { + eateryRepository.pingEateries() + } mealFilterFlow.value = filter } @@ -182,17 +193,24 @@ class UpcomingViewModel @Inject constructor( mealFilterFlow.value = nextMeal() ?: MealFilter.LATE_DINNER } - fun selectDayOffset(offset: Int) { + fun selectDayOffset(offset: Int, pingAgain: Boolean) { + if (pingAgain) { + eateryRepository.pingEateries() + } selectedDayFlow.update { offset } } + fun pingEateries() { + eateryRepository.pingEateries() + } + /** * nextMeal returns the next MealFilter that ends after the current time. * (Ex. If it is 10:45am, the next meal is Lunch ending at 2:30pm) * Returns null when no meals end after the current time. */ private fun nextMeal(): MealFilter? { - return MealFilter.values() + return MealFilter.entries .find { it.endTimes >= LocalDateTime.now().hour + LocalDateTime.now().minute / 60f } } } From b4e9be759db88d2eb3fc66a19260e1bb28648263 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 26 Oct 2025 16:00:44 -0400 Subject: [PATCH 040/126] Cleanup --- .../android/eatery/data/NetworkingApi.kt | 64 ++++--------------- .../details/EateryHourBottomSheet.kt | 9 +-- .../ui/components/general/EateryCard.kt | 2 - 3 files changed, 18 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index ad231561..6d597e7b 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -1,15 +1,22 @@ package com.cornellappdev.android.eatery.data -import com.cornellappdev.android.eatery.BuildConfig -import com.cornellappdev.android.eatery.data.models.* +import com.cornellappdev.android.eatery.data.models.AccountsResponse +import com.cornellappdev.android.eatery.data.models.ApiResponse +import com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.Event +import com.cornellappdev.android.eatery.data.models.GetApiAccountsParams +import com.cornellappdev.android.eatery.data.models.GetApiRequestBody +import com.cornellappdev.android.eatery.data.models.GetApiResponse +import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryParams +import com.cornellappdev.android.eatery.data.models.GetApiUserParams +import com.cornellappdev.android.eatery.data.models.ReportSendBody +import com.cornellappdev.android.eatery.data.models.TransactionsResponse +import com.cornellappdev.android.eatery.data.models.User import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Url -import java.text.SimpleDateFormat -import java.time.Duration -import java.util.* interface NetworkApi { @POST() @@ -47,49 +54,4 @@ interface NetworkApi { suspend fun sendReport( @Body report: ReportSendBody ): GetApiResponse -} - -fun generateUserBody(sessionId: String): GetApiRequestBody { - return GetApiRequestBody( - version = "1", - method = "retrieve", - params = GetApiUserParams( - sessionId = sessionId - ) - ) -} - -fun generateAccountsBody( - sessionId: String, - userId: String -): GetApiRequestBody { - return GetApiRequestBody( - version = "1", - method = "retrieveAccountsByUser", - params = GetApiAccountsParams( - sessionId = sessionId, - userId = userId - ) - ) -} - -fun generateTransactionHistoryBody( - sessionId: String, userId: String, endDate: Date -): GetApiRequestBody { - val startDate = Date.from(endDate.toInstant().minus(Duration.ofDays(5000))) - return GetApiRequestBody( - version = "1", - method = "retrieveTransactionHistory", - params = GetApiTransactionHistoryParams( - paymentSystemType = 0, - sessionId = sessionId, - queryCriteria = GetApiTransactionHistoryQueryCriteria( - endDate = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(endDate), - startDate = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(startDate), - maxReturn = 5000, - institutionId = BuildConfig.CORNELL_INSTITUTION_ID, - userId = userId - ) - ) - ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryHourBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryHourBottomSheet.kt index 4e9eb5d3..e7003cc0 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryHourBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryHourBottomSheet.kt @@ -44,10 +44,11 @@ import com.cornellappdev.android.eatery.ui.theme.Yellow */ @Composable fun EateryHourBottomSheet( - eatery: Eatery, + eatery: Eatery?, onDismiss: () -> Unit, onReportIssue: () -> Unit ) { + if (eatery == null) return Column( modifier = Modifier .fillMaxWidth() @@ -95,9 +96,9 @@ fun EateryHourBottomSheet( Text( modifier = Modifier.padding(top = 2.dp), text = - if (openUntil == null) "Closed" - else if (eatery.isClosingSoon()) "Closing at $openUntil" - else ("Open until $openUntil"), + if (openUntil == null) "Closed" + else if (eatery.isClosingSoon()) "Closing at $openUntil" + else ("Open until $openUntil"), style = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 16.sp ), diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt index 4954a4e6..ad6354ed 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt @@ -1,6 +1,5 @@ package com.cornellappdev.android.eatery.ui.components.general -import android.util.Log import androidx.compose.animation.Crossfade import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -118,7 +117,6 @@ fun EateryCard( backgroundColor = Color.White, modifier = modifier ) { - Log.d("TAG", "EateryCard:still alvie ") Column { Box { Crossfade( From 39e4d57b4c736bc024e16937cd945737c364097c Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 26 Oct 2025 16:26:10 -0400 Subject: [PATCH 041/126] Show compare menus button only if API response succeeds --- .../android/eatery/ui/screens/HomeScreen.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index b82ef38e..2887475e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -176,17 +176,19 @@ fun HomeScreen( Scaffold( scaffoldState = scaffoldState, floatingActionButton = { - CompareMenusFAB( - modifier = Modifier.scale(compareMenusScale), - ) { - if (!showFAB) { - return@CompareMenusFAB - } + if (eateriesApiResponse is EateryApiResponse.Success && eateriesApiResponse.data.size >= 2) { + CompareMenusFAB( + modifier = Modifier.scale(compareMenusScale), + ) { + if (!showFAB) { + return@CompareMenusFAB + } - showFAB = false - coroutineScope.launch { - sheetContent = BottomSheetContent.COMPARE_MENUS - modalBottomSheetState.show() + showFAB = false + coroutineScope.launch { + sheetContent = BottomSheetContent.COMPARE_MENUS + modalBottomSheetState.show() + } } } }, From 214250155b231dcb6db6c5c92b93e79eae31d6dc Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 27 Oct 2025 12:58:50 -0400 Subject: [PATCH 042/126] Make eatery parameter in EateryHourBottomSheet nonnullable --- .../details/EateryHourBottomSheet.kt | 3 +-- .../eatery/ui/screens/CompareMenusScreen.kt | 26 ++++++++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryHourBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryHourBottomSheet.kt index e7003cc0..0c97a080 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryHourBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryHourBottomSheet.kt @@ -44,11 +44,10 @@ import com.cornellappdev.android.eatery.ui.theme.Yellow */ @Composable fun EateryHourBottomSheet( - eatery: Eatery?, + eatery: Eatery, onDismiss: () -> Unit, onReportIssue: () -> Unit ) { - if (eatery == null) return Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt index a18ebf0b..9fe4aa7d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt @@ -157,17 +157,23 @@ fun CompareMenusScreen( ModalBottomSheetLayout( sheetState = modalBottomSheetState, sheetContent = { when (sheetContent) { - BottomSheetContent.HOURS -> EateryHourBottomSheet(onDismiss = { - coroutineScope.launch { - modalBottomSheetState.hide() + BottomSheetContent.HOURS -> { + val eatery = eateries.getOrNull(firstPagerState.currentPage) + eatery?.let { + EateryHourBottomSheet(onDismiss = { + coroutineScope.launch { + modalBottomSheetState.hide() + } + }, eatery = eatery, onReportIssue = { + sheetContent = BottomSheetContent.REPORT + }) } - }, eatery = eateries[firstPagerState.currentPage], onReportIssue = { - sheetContent = BottomSheetContent.REPORT - }) + } BottomSheetContent.REPORT -> { eateries[0].id?.let { - ReportBottomSheet(issue = issue, + ReportBottomSheet( + issue = issue, eateryid = it, sendReport = { issue, report, eateryid -> compareMenusViewModel.sendReport( @@ -289,9 +295,9 @@ private fun MenuPager( Text( modifier = Modifier.padding(top = 2.dp), text = - if (openUntil == null) "Closed" - else if (eateries[page].isClosingSoon()) "Closing at $openUntil" - else ("Open until $openUntil"), + if (openUntil == null) "Closed" + else if (eateries[page].isClosingSoon()) "Closing at $openUntil" + else ("Open until $openUntil"), style = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 16.sp From a3f36fa038d7f934c142cdb4594ea07309856d22 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 27 Oct 2025 14:28:39 -0400 Subject: [PATCH 043/126] Remove pingAgain, improve error state handling, ensure filter row state remembered, fix resetFilter --- .../eatery/ui/components/general/FilterRow.kt | 6 +- .../android/eatery/ui/screens/HomeScreen.kt | 132 +++++++------- .../eatery/ui/screens/UpcomingMenuScreen.kt | 168 ++++++++++-------- .../eatery/ui/viewmodels/HomeViewModel.kt | 10 +- .../eatery/ui/viewmodels/UpcomingViewModel.kt | 20 +-- 5 files changed, 172 insertions(+), 164 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/FilterRow.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/FilterRow.kt index a94f14d8..e3bdda5e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/FilterRow.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/FilterRow.kt @@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -32,10 +34,12 @@ fun FilterRow( onFilterClicked: (Filter) -> Unit, customItemsBefore: LazyListScope.() -> Unit = {}, customItemsAfter: LazyListScope.() -> Unit = {}, + rowState: LazyListState = rememberLazyListState() ) { LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp) + contentPadding = PaddingValues(horizontal = 16.dp), + state = rowState ) { customItemsBefore() items(filters) { filter -> diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 2887475e..604084ad 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -232,15 +233,12 @@ fun HomeScreen( } }, onFilterClicked = { filter -> - homeViewModel.toggleFilter( - filter = filter, - pingAgain = isErrorState(eateriesApiResponse) - ) + pingIfError(eateriesApiResponse, homeViewModel) + homeViewModel.toggleFilter(filter) }, onResetFilters = { - homeViewModel.resetFilters( - pingAgain = isErrorState(eateriesApiResponse) - ) + pingIfError(eateriesApiResponse, homeViewModel) + homeViewModel.resetFilters() }, filters = homeViewModel.homeScreenFilters, isGridView = isGridView, @@ -265,6 +263,15 @@ fun HomeScreen( }) } +private fun pingIfError( + eateriesApiResponse: EateryApiResponse>, + homeViewModel: HomeViewModel +) { + if (eateriesApiResponse is EateryApiResponse.Error) { + homeViewModel.pingEateries() + } +} + @Composable @OptIn(ExperimentalMaterialApi::class) private fun SheetContent( @@ -339,55 +346,36 @@ private fun HomeScrollableMainContent( if (favorites.isNotEmpty()) { lastFavorite = favorites[0] } - if (isErrorState(eateriesApiResponse)) { - Column(modifier = Modifier.fillMaxSize()) { - HomeStickyHeader( - collapsed = isFirstVisible.value, - loaded = false, - onSearchClick = onSearchClick, - onNotificationsClick = onNotificationsClick - ) - HomeMainHeader( - onSearchClick = onSearchClick, - selectedFilters = selectedFilters, - filters = filters, - onFilterClicked = onFilterClicked - ) - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorContent(onTryAgain = onReload) + val filterRowState = rememberLazyListState() + val homeLazyColumn: @Composable (LazyListScope.() -> Unit) -> Unit = { content -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + ) { + stickyHeader { + HomeStickyHeader( + collapsed = isFirstVisible.value, + loaded = eateriesApiResponse is EateryApiResponse.Success, + onSearchClick = onSearchClick, + onNotificationsClick = onNotificationsClick + ) } + item { + HomeMainHeader( + onSearchClick = onSearchClick, + selectedFilters = selectedFilters, + onFilterClicked = onFilterClicked, + filters = filters, + filterRowState = filterRowState + ) + } + content() } - return } - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - ) { - stickyHeader { - HomeStickyHeader( - collapsed = isFirstVisible.value, - loaded = eateriesApiResponse is EateryApiResponse.Success, - onSearchClick = onSearchClick, - onNotificationsClick = onNotificationsClick - ) - } - item { - HomeMainHeader( - onSearchClick = onSearchClick, - selectedFilters = selectedFilters, - onFilterClicked = onFilterClicked, - filters = filters, - ) - } - when (eateriesApiResponse) { - is EateryApiResponse.Pending -> { - items(MainLoadingItem.mainItems) { item -> - CreateMainLoadingItem(item, shimmer) - } - } - - is EateryApiResponse.Success -> { + when (eateriesApiResponse) { + is EateryApiResponse.Success -> { + homeLazyColumn { regularContent( eateriesApiResponse, selectedFilters, @@ -403,17 +391,39 @@ private fun HomeScrollableMainContent( nearestEateries ) } + } - EateryApiResponse.Error -> { - // impossible + is EateryApiResponse.Pending -> { + homeLazyColumn { + items(MainLoadingItem.mainItems) { item -> + CreateMainLoadingItem(item, shimmer) + } + } + } + + is EateryApiResponse.Error -> { + Column(modifier = Modifier.fillMaxSize()) { + HomeStickyHeader( + collapsed = isFirstVisible.value, + loaded = false, + onSearchClick = onSearchClick, + onNotificationsClick = onNotificationsClick + ) + HomeMainHeader( + onSearchClick = onSearchClick, + selectedFilters = selectedFilters, + filters = filters, + onFilterClicked = onFilterClicked, + filterRowState = filterRowState + ) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + ErrorContent(onTryAgain = onReload) + } } } } } -private fun isErrorState(eateriesApiResponse: EateryApiResponse>): Boolean = - eateriesApiResponse is EateryApiResponse.Error - @Composable fun ErrorContent(onTryAgain: () -> Unit) { Column( @@ -726,6 +736,7 @@ private fun HomeMainHeader( selectedFilters: List, filters: List, onFilterClicked: (Filter) -> Unit, + filterRowState: LazyListState ) { SearchBar( searchText = "", @@ -740,12 +751,11 @@ private fun HomeMainHeader( onCancelClicked = {}, enabled = false ) - - FilterRow( currentFiltersSelected = selectedFilters, onFilterClicked = onFilterClicked, - filters = filters + filters = filters, + rowState = filterRowState ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt index 4d6d7e0a..28ac0cab 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -97,9 +98,9 @@ fun UpcomingMenuScreen( sheetState = modalBottomSheetState, selectedMeal = viewState.mealFilter, onSubmit = { + pingIfError(viewState, upcomingViewModel) upcomingViewModel.changeMealFilter( - filter = it, - pingAgain = isErrorState(viewState) + filter = it ) }, onReset = { @@ -134,15 +135,16 @@ fun UpcomingMenuScreen( LocalDate.now().plusDays(it.toLong()).dayOfMonth }, onClick = { i -> + pingIfError(viewState, upcomingViewModel) upcomingViewModel.selectDayOffset( - offset = i, - pingAgain = isErrorState(viewState) + offset = i ) }, closedDays = null ) } } + val filterRowState = rememberLazyListState() val filterRow = @Composable { FilterRow( customItemsBefore = { @@ -165,93 +167,97 @@ fun UpcomingMenuScreen( filters = upcomingViewModel.upcomingMenuFilters, currentFiltersSelected = viewState.selectedFilters, onFilterClicked = { filter -> + pingIfError(viewState, upcomingViewModel) upcomingViewModel.toggleFilter( - filter = filter, - pingAgain = isErrorState(viewState) + filter ) }, + rowState = filterRowState ) } - - if (isErrorState(viewState)) { - Column(modifier = Modifier.fillMaxSize()) { - upcomingMenuHeader() - calendarWeekSelector() - filterRow() - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + val upcomingLazyColumn: @Composable (LazyListScope.() -> Unit) -> Unit = + { content -> + LazyColumn( + state = innerListState, modifier = Modifier.fillMaxSize() ) { - ErrorContent(onTryAgain = upcomingViewModel::pingEateries) + stickyHeader { + upcomingMenuHeader() + } + item { + calendarWeekSelector() + } + item { + filterRow() + } + content() } } - } else { - LazyColumn( - state = innerListState, modifier = Modifier.fillMaxSize() - ) { - stickyHeader { - upcomingMenuHeader() - } - item { - calendarWeekSelector() - } - item { - filterRow() - } - when (val menus = viewState.menus) { - is EateryApiResponse.Pending -> { - items(UpcomingLoadingItem.upcomingItems) { item -> - CreateUpcomingLoadingItem( - item, - shimmer - ) - } - } - - is EateryApiResponse.Success -> { - if (menus.data.isEmpty()) { - item { - Box( - modifier = Modifier - .fillParentMaxHeight(0.7f) - .fillMaxWidth() - ) { - NoEateryFound( - modifier = Modifier.align( - Alignment.Center - ), resetFilters = { - upcomingViewModel.resetFilters(pingAgain = false) - }) - } - Spacer(modifier = Modifier.height(12.dp)) + when (val menus = viewState.menus) { + is EateryApiResponse.Success -> { + upcomingLazyColumn { + if (menus.data.isEmpty()) { + item { + Box( + modifier = Modifier + .fillParentMaxHeight(0.7f) + .fillMaxWidth() + ) { + NoEateryFound( + modifier = Modifier.align( + Alignment.Center + ), resetFilters = { + upcomingViewModel.resetFilters() + }) } + Spacer(modifier = Modifier.height(12.dp)) } - items(menus.data) { - Column(modifier = Modifier.padding(horizontal = 12.dp)) { - Text( - modifier = Modifier.padding(start = 6.dp), - text = it.header, - style = EateryBlueTypography.h4 + } + items(menus.data) { + Column(modifier = Modifier.padding(horizontal = 12.dp)) { + Text( + modifier = Modifier.padding(start = 6.dp), + text = it.header, + style = EateryBlueTypography.h4 + ) + Spacer(modifier = Modifier.height(16.dp)) + it.menuCards.forEach { eatery -> + MenuCard( + menuCardViewState = eatery, + selectEatery = { + onEateryClick(eatery.eateryId) + }, + onEateryCardContract = { + appStorePopupRepository.requestRatingPopup() + } ) - Spacer(modifier = Modifier.height(16.dp)) - it.menuCards.forEach { eatery -> - MenuCard( - eatery, - selectEatery = { - onEateryClick(eatery.eateryId) - }, - onEateryCardContract = { - appStorePopupRepository.requestRatingPopup() - } - ) - Spacer(modifier = Modifier.height(12.dp)) - } + Spacer(modifier = Modifier.height(12.dp)) } } } + } + } + + is EateryApiResponse.Pending -> { + upcomingLazyColumn { + items(UpcomingLoadingItem.upcomingItems) { item -> + CreateUpcomingLoadingItem( + item, + shimmer + ) + } + } + } - EateryApiResponse.Error -> { - /* Handled above */ + is EateryApiResponse.Error -> { + Column(modifier = Modifier.fillMaxSize()) { + upcomingMenuHeader() + calendarWeekSelector() + filterRow() + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorContent(onTryAgain = upcomingViewModel::pingEateries) } } } @@ -261,8 +267,14 @@ fun UpcomingMenuScreen( } } -private fun isErrorState(viewState: UpcomingMenusViewState): Boolean = - viewState.menus is EateryApiResponse.Error +private fun pingIfError( + viewState: UpcomingMenusViewState, + upcomingViewModel: UpcomingViewModel +) { + if (viewState.menus is EateryApiResponse.Error) { + upcomingViewModel.pingEateries() + } +} @Composable @OptIn(ExperimentalAnimationApi::class) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 61b4380e..a3176f4b 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -125,10 +125,7 @@ class HomeViewModel @Inject constructor( bigPopUp = bool } - fun toggleFilter(filter: Filter, pingAgain: Boolean) { - if (pingAgain) { - eateryRepository.pingEateries() - } + fun toggleFilter(filter: Filter) { _filtersFlow.update { it.updateFilters(filter) } @@ -141,10 +138,7 @@ class HomeViewModel @Inject constructor( _filtersFlow.value = newList } - fun resetFilters(pingAgain: Boolean) = { - if (pingAgain) { - eateryRepository.pingEateries() - } + fun resetFilters() { viewModelScope.launch { _filtersFlow.value = listOf() } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt index a5f73634..9a387971 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt @@ -165,27 +165,18 @@ class UpcomingViewModel @Inject constructor( UpcomingMenusViewState(mealFilter = nextMeal() ?: MealFilter.LATE_DINNER) ) - fun toggleFilter(filter: Filter, pingAgain: Boolean) { - if (pingAgain) { - eateryRepository.pingEateries() - } + fun toggleFilter(filter: Filter) { selectedFiltersFlow.update { it.updateFilters(filter) } } - fun resetFilters(pingAgain: Boolean) { - if (pingAgain) { - eateryRepository.pingEateries() - } + fun resetFilters() { resetMealFilter() selectedFiltersFlow.update { emptyList() } } - fun changeMealFilter(filter: MealFilter, pingAgain: Boolean) { - if (pingAgain) { - eateryRepository.pingEateries() - } + fun changeMealFilter(filter: MealFilter) { mealFilterFlow.value = filter } @@ -193,10 +184,7 @@ class UpcomingViewModel @Inject constructor( mealFilterFlow.value = nextMeal() ?: MealFilter.LATE_DINNER } - fun selectDayOffset(offset: Int, pingAgain: Boolean) { - if (pingAgain) { - eateryRepository.pingEateries() - } + fun selectDayOffset(offset: Int) { selectedDayFlow.update { offset } } From 92ec789fe5855475f66c1ad5ec39eeec9976549e Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 27 Oct 2025 17:27:08 -0400 Subject: [PATCH 044/126] Move pinging logic to VM --- .../ui/components/upcoming/MealBottomSheet.kt | 1 - .../android/eatery/ui/screens/HomeScreen.kt | 19 ++----------------- .../eatery/ui/screens/UpcomingMenuScreen.kt | 16 ++++------------ .../eatery/ui/viewmodels/HomeViewModel.kt | 12 ++++++++---- .../eatery/ui/viewmodels/UpcomingViewModel.kt | 16 +++++++++------- 5 files changed, 23 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt index 063320cf..9493b1ae 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt @@ -48,7 +48,6 @@ import com.cornellappdev.android.eatery.ui.theme.GrayZero fun MealBottomSheet( selectedMeal: MealFilter, onSubmit: (MealFilter) -> Unit, - onReset: () -> Unit, hide: () -> Unit, sheetState: ModalBottomSheetState ) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 604084ad..2ca7df48 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -232,14 +232,8 @@ fun HomeScreen( homeViewModel.removeFavorite(eatery.id) } }, - onFilterClicked = { filter -> - pingIfError(eateriesApiResponse, homeViewModel) - homeViewModel.toggleFilter(filter) - }, - onResetFilters = { - pingIfError(eateriesApiResponse, homeViewModel) - homeViewModel.resetFilters() - }, + onFilterClicked = homeViewModel::onToggleFilterPressed, + onResetFilters = homeViewModel::onResetFiltersClicked, filters = homeViewModel.homeScreenFilters, isGridView = isGridView, onListClick = { isGridView = false }, @@ -263,15 +257,6 @@ fun HomeScreen( }) } -private fun pingIfError( - eateriesApiResponse: EateryApiResponse>, - homeViewModel: HomeViewModel -) { - if (eateriesApiResponse is EateryApiResponse.Error) { - homeViewModel.pingEateries() - } -} - @Composable @OptIn(ExperimentalMaterialApi::class) private fun SheetContent( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt index 28ac0cab..eedaa4df 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt @@ -97,15 +97,7 @@ fun UpcomingMenuScreen( MealBottomSheet( sheetState = modalBottomSheetState, selectedMeal = viewState.mealFilter, - onSubmit = { - pingIfError(viewState, upcomingViewModel) - upcomingViewModel.changeMealFilter( - filter = it - ) - }, - onReset = { - upcomingViewModel.resetMealFilter() - }, + onSubmit = upcomingViewModel::onMealFilterChanged, hide = { coroutineScope.launch { modalBottomSheetState.hide() @@ -205,9 +197,9 @@ fun UpcomingMenuScreen( NoEateryFound( modifier = Modifier.align( Alignment.Center - ), resetFilters = { - upcomingViewModel.resetFilters() - }) + ), + resetFilters = upcomingViewModel::onResetFiltersClicked + ) } Spacer(modifier = Modifier.height(12.dp)) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index a3176f4b..4e4272cc 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -125,7 +125,10 @@ class HomeViewModel @Inject constructor( bigPopUp = bool } - fun toggleFilter(filter: Filter) { + fun onToggleFilterPressed(filter: Filter) { + if (eateryFlow.value is EateryApiResponse.Error) { + pingEateries() + } _filtersFlow.update { it.updateFilters(filter) } @@ -138,10 +141,11 @@ class HomeViewModel @Inject constructor( _filtersFlow.value = newList } - fun resetFilters() { - viewModelScope.launch { - _filtersFlow.value = listOf() + fun onResetFiltersClicked() { + if (eateryFlow.value is EateryApiResponse.Error) { + pingEateries() } + _filtersFlow.update { emptyList() } } fun addFavorite(eateryId: Int?) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt index 9a387971..ced88c0c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt @@ -171,19 +171,21 @@ class UpcomingViewModel @Inject constructor( } } - fun resetFilters() { - resetMealFilter() + fun onResetFiltersClicked() { + if (viewStateFlow.value.menus is EateryApiResponse.Error) { + pingEateries() + } + mealFilterFlow.value = nextMeal() ?: MealFilter.LATE_DINNER selectedFiltersFlow.update { emptyList() } } - fun changeMealFilter(filter: MealFilter) { + fun onMealFilterChanged(filter: MealFilter) { + if (viewStateFlow.value.menus is EateryApiResponse.Error) { + pingEateries() + } mealFilterFlow.value = filter } - fun resetMealFilter() { - mealFilterFlow.value = nextMeal() ?: MealFilter.LATE_DINNER - } - fun selectDayOffset(offset: Int) { selectedDayFlow.update { offset } } From bfd6b7059c079fd2a4edcc7b4460fdf09471bf01 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 27 Oct 2025 18:15:17 -0400 Subject: [PATCH 045/126] Finish ping refactor --- .../eatery/ui/screens/UpcomingMenuScreen.kt | 24 ++----------------- .../eatery/ui/viewmodels/UpcomingViewModel.kt | 8 ++++++- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt index eedaa4df..65747ccb 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt @@ -50,7 +50,6 @@ import com.cornellappdev.android.eatery.ui.components.upcoming.UpcomingLoadingIt import com.cornellappdev.android.eatery.ui.components.upcoming.UpcomingLoadingItem.Companion.CreateUpcomingLoadingItem import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography -import com.cornellappdev.android.eatery.ui.viewmodels.UpcomingMenusViewState import com.cornellappdev.android.eatery.ui.viewmodels.UpcomingViewModel import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse import com.cornellappdev.android.eatery.util.AppStorePopupRepository @@ -126,12 +125,7 @@ fun UpcomingMenuScreen( days = (0 until 7).map { LocalDate.now().plusDays(it.toLong()).dayOfMonth }, - onClick = { i -> - pingIfError(viewState, upcomingViewModel) - upcomingViewModel.selectDayOffset( - offset = i - ) - }, + onClick = upcomingViewModel::selectDayOffset, closedDays = null ) } @@ -158,12 +152,7 @@ fun UpcomingMenuScreen( }, filters = upcomingViewModel.upcomingMenuFilters, currentFiltersSelected = viewState.selectedFilters, - onFilterClicked = { filter -> - pingIfError(viewState, upcomingViewModel) - upcomingViewModel.toggleFilter( - filter - ) - }, + onFilterClicked = upcomingViewModel::onToggleFilterClicked, rowState = filterRowState ) } @@ -259,15 +248,6 @@ fun UpcomingMenuScreen( } } -private fun pingIfError( - viewState: UpcomingMenusViewState, - upcomingViewModel: UpcomingViewModel -) { - if (viewState.menus is EateryApiResponse.Error) { - upcomingViewModel.pingEateries() - } -} - @Composable @OptIn(ExperimentalAnimationApi::class) private fun UpcomingMenuHeader(isFirstVisible: State) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt index ced88c0c..1ce69b16 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt @@ -165,7 +165,10 @@ class UpcomingViewModel @Inject constructor( UpcomingMenusViewState(mealFilter = nextMeal() ?: MealFilter.LATE_DINNER) ) - fun toggleFilter(filter: Filter) { + fun onToggleFilterClicked(filter: Filter) { + if (viewStateFlow.value.menus is EateryApiResponse.Error) { + pingEateries() + } selectedFiltersFlow.update { it.updateFilters(filter) } @@ -187,6 +190,9 @@ class UpcomingViewModel @Inject constructor( } fun selectDayOffset(offset: Int) { + if (viewStateFlow.value.menus is EateryApiResponse.Error) { + pingEateries() + } selectedDayFlow.update { offset } } From 890e4ec3247442a07a547adb429b66d5b003a8fc Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 27 Oct 2025 18:32:28 -0400 Subject: [PATCH 046/126] Remove inline Composables --- .../eatery/ui/screens/UpcomingMenuScreen.kt | 214 ++++++++++++------ 1 file changed, 147 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt index 65747ccb..5aa0011c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -40,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.android.eatery.ui.components.general.CalendarWeekSelector +import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterButton import com.cornellappdev.android.eatery.ui.components.general.FilterRow import com.cornellappdev.android.eatery.ui.components.general.MealFilter @@ -56,6 +58,7 @@ import com.cornellappdev.android.eatery.util.AppStorePopupRepository import com.cornellappdev.android.eatery.util.appStorePopupRepository import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -106,76 +109,34 @@ fun UpcomingMenuScreen( }, content = { val innerListState = rememberLazyListState() + val filterRowState = rememberLazyListState() val isFirstVisible = remember { derivedStateOf { innerListState.firstVisibleItemIndex > 0 } } - val upcomingMenuHeader = @Composable { - UpcomingMenuHeader(isFirstVisible) - } - val calendarWeekSelector = @Composable { - Box( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) - ) { - CalendarWeekSelector( - dayNames = (0 until 7).map { - LocalDate.now().plusDays(it.toLong()) - .format(DateTimeFormatter.ofPattern("EEE")) - }, - currSelectedDay = viewState.selectedDay, - selectedDay = viewState.selectedDay, - days = (0 until 7).map { - LocalDate.now().plusDays(it.toLong()).dayOfMonth + when (val menus = viewState.menus) { + is EateryApiResponse.Success -> { + UpcomingLazyColumn( + innerListState = innerListState, + upcomingMenuHeader = { UpcomingMenuHeader(isFirstVisible) }, + calendarWeekSelector = { + CalendarWeekSelector( + selectedDay = viewState.selectedDay, + selectDayOffset = upcomingViewModel::selectDayOffset + ) }, - onClick = upcomingViewModel::selectDayOffset, - closedDays = null - ) - } - } - val filterRowState = rememberLazyListState() - val filterRow = @Composable { - FilterRow( - customItemsBefore = { - item { - FilterButton( - onFilterClicked = { - coroutineScope.launch { - modalBottomSheetState.show() - } - }, - selected = true, - text = when (viewState.mealFilter) { - MealFilter.LATE_DINNER -> "Late Dinner" - else -> viewState.mealFilter.text.first() + filterRow = { + UpcomingFilterRow( + coroutineScope = coroutineScope, + showModalBottomSheet = { + modalBottomSheetState.show() }, - icon = Icons.Default.ExpandMore + mealFilter = viewState.mealFilter, + upcomingMenuFilters = upcomingViewModel.upcomingMenuFilters, + selectedFilters = viewState.selectedFilters, + onToggleFilterClicked = upcomingViewModel::onToggleFilterClicked, + filterRowState = filterRowState ) } - }, - filters = upcomingViewModel.upcomingMenuFilters, - currentFiltersSelected = viewState.selectedFilters, - onFilterClicked = upcomingViewModel::onToggleFilterClicked, - rowState = filterRowState - ) - } - val upcomingLazyColumn: @Composable (LazyListScope.() -> Unit) -> Unit = - { content -> - LazyColumn( - state = innerListState, modifier = Modifier.fillMaxSize() ) { - stickyHeader { - upcomingMenuHeader() - } - item { - calendarWeekSelector() - } - item { - filterRow() - } - content() - } - } - when (val menus = viewState.menus) { - is EateryApiResponse.Success -> { - upcomingLazyColumn { if (menus.data.isEmpty()) { item { Box( @@ -219,7 +180,29 @@ fun UpcomingMenuScreen( } is EateryApiResponse.Pending -> { - upcomingLazyColumn { + UpcomingLazyColumn( + innerListState = innerListState, + upcomingMenuHeader = { UpcomingMenuHeader(isFirstVisible) }, + calendarWeekSelector = { + CalendarWeekSelector( + selectedDay = viewState.selectedDay, + selectDayOffset = upcomingViewModel::selectDayOffset + ) + }, + filterRow = { + UpcomingFilterRow( + coroutineScope = coroutineScope, + showModalBottomSheet = { + modalBottomSheetState.show() + }, + mealFilter = viewState.mealFilter, + upcomingMenuFilters = upcomingViewModel.upcomingMenuFilters, + selectedFilters = viewState.selectedFilters, + onToggleFilterClicked = upcomingViewModel::onToggleFilterClicked, + filterRowState = filterRowState + ) + } + ) { items(UpcomingLoadingItem.upcomingItems) { item -> CreateUpcomingLoadingItem( item, @@ -231,9 +214,22 @@ fun UpcomingMenuScreen( is EateryApiResponse.Error -> { Column(modifier = Modifier.fillMaxSize()) { - upcomingMenuHeader() - calendarWeekSelector() - filterRow() + UpcomingMenuHeader(isFirstVisible) + CalendarWeekSelector( + selectedDay = viewState.selectedDay, + selectDayOffset = upcomingViewModel::selectDayOffset + ) + UpcomingFilterRow( + coroutineScope = coroutineScope, + showModalBottomSheet = { + modalBottomSheetState.show() + }, + mealFilter = viewState.mealFilter, + upcomingMenuFilters = upcomingViewModel.upcomingMenuFilters, + selectedFilters = viewState.selectedFilters, + onToggleFilterClicked = upcomingViewModel::onToggleFilterClicked, + filterRowState = filterRowState + ) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -248,6 +244,90 @@ fun UpcomingMenuScreen( } } +@Composable +private fun CalendarWeekSelector( + selectedDay: Int, + selectDayOffset: (Int) -> Unit, +) { + Box( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) + ) { + CalendarWeekSelector( + dayNames = (0 until 7).map { + LocalDate.now().plusDays(it.toLong()) + .format(DateTimeFormatter.ofPattern("EEE")) + }, + currSelectedDay = selectedDay, + selectedDay = selectedDay, + days = (0 until 7).map { + LocalDate.now().plusDays(it.toLong()).dayOfMonth + }, + onClick = selectDayOffset, + closedDays = null + ) + } +} + +@Composable +private fun UpcomingFilterRow( + coroutineScope: CoroutineScope, + showModalBottomSheet: suspend () -> Unit, + mealFilter: MealFilter, + upcomingMenuFilters: List, + selectedFilters: List, + onToggleFilterClicked: (Filter) -> Unit, + filterRowState: LazyListState, +) { + FilterRow( + customItemsBefore = { + item { + FilterButton( + onFilterClicked = { + coroutineScope.launch { + showModalBottomSheet() + } + }, + selected = true, + text = when (mealFilter) { + MealFilter.LATE_DINNER -> "Late Dinner" + else -> mealFilter.text.first() + }, + icon = Icons.Default.ExpandMore + ) + } + }, + filters = upcomingMenuFilters, + currentFiltersSelected = selectedFilters, + onFilterClicked = onToggleFilterClicked, + rowState = filterRowState + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun UpcomingLazyColumn( + innerListState: LazyListState, + upcomingMenuHeader: @Composable () -> Unit, + calendarWeekSelector: @Composable () -> Unit, + filterRow: @Composable () -> Unit, + content: LazyListScope.() -> Unit +) { + LazyColumn( + state = innerListState, modifier = Modifier.fillMaxSize() + ) { + stickyHeader { + upcomingMenuHeader() + } + item { + calendarWeekSelector() + } + item { + filterRow() + } + content() + } +} + @Composable @OptIn(ExperimentalAnimationApi::class) private fun UpcomingMenuHeader(isFirstVisible: State) { From 50bb46186b4ff4a705ab18eeb245b085bf06a8aa Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 27 Oct 2025 18:41:26 -0400 Subject: [PATCH 047/126] Remove more inline Composables --- .../android/eatery/ui/screens/HomeScreen.kt | 92 +++++++++++++------ 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 2ca7df48..260a1a18 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -322,6 +322,7 @@ private fun HomeScrollableMainContent( onReload: () -> Unit ) { val listState = rememberLazyListState() + val filterRowState = rememberLazyListState() val isFirstVisible = remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } @@ -331,36 +332,19 @@ private fun HomeScrollableMainContent( if (favorites.isNotEmpty()) { lastFavorite = favorites[0] } - val filterRowState = rememberLazyListState() - val homeLazyColumn: @Composable (LazyListScope.() -> Unit) -> Unit = { content -> - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - ) { - stickyHeader { - HomeStickyHeader( - collapsed = isFirstVisible.value, - loaded = eateriesApiResponse is EateryApiResponse.Success, - onSearchClick = onSearchClick, - onNotificationsClick = onNotificationsClick - ) - } - item { - HomeMainHeader( - onSearchClick = onSearchClick, - selectedFilters = selectedFilters, - onFilterClicked = onFilterClicked, - filters = filters, - filterRowState = filterRowState - ) - } - content() - } - } when (eateriesApiResponse) { is EateryApiResponse.Success -> { - homeLazyColumn { + HomeLazyColumn( + listState = listState, + collapsed = isFirstVisible.value, + loaded = true, + onSearchClick = onSearchClick, + onNotificationsClick = onNotificationsClick, + selectedFilters = selectedFilters, + onFilterClicked = onFilterClicked, + filters = filters, + filterRowState = filterRowState + ) { regularContent( eateriesApiResponse, selectedFilters, @@ -379,7 +363,17 @@ private fun HomeScrollableMainContent( } is EateryApiResponse.Pending -> { - homeLazyColumn { + HomeLazyColumn( + listState = listState, + collapsed = isFirstVisible.value, + loaded = false, + onSearchClick = onSearchClick, + onNotificationsClick = onNotificationsClick, + selectedFilters = selectedFilters, + onFilterClicked = onFilterClicked, + filters = filters, + filterRowState = filterRowState + ) { items(MainLoadingItem.mainItems) { item -> CreateMainLoadingItem(item, shimmer) } @@ -409,6 +403,46 @@ private fun HomeScrollableMainContent( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HomeLazyColumn( + listState: LazyListState, + collapsed: Boolean, + loaded: Boolean, + onSearchClick: () -> Unit, + onNotificationsClick: () -> Unit, + selectedFilters: List, + onFilterClicked: (Filter) -> Unit, + filters: List, + filterRowState: LazyListState, + content: LazyListScope.() -> Unit +) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + ) { + stickyHeader { + HomeStickyHeader( + collapsed = collapsed, + loaded = loaded, + onSearchClick = onSearchClick, + onNotificationsClick = onNotificationsClick + ) + } + item { + HomeMainHeader( + onSearchClick = onSearchClick, + selectedFilters = selectedFilters, + onFilterClicked = onFilterClicked, + filters = filters, + filterRowState = filterRowState + ) + } + content() + } +} + @Composable fun ErrorContent(onTryAgain: () -> Unit) { Column( From 7bfb29565946358e2b415155fadd51bf1ba7694b Mon Sep 17 00:00:00 2001 From: Caleb Shim <74190657+caleb-bit@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:33:16 -0400 Subject: [PATCH 048/126] Fix logo Image in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ef985778..6617eb88 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Eatery Blue - Browse. Search. Eat. -

+

Eatery was the first app released by AppDev. With over 6,000 students using it every month, it enables students to browse menus and discover places to eat on Cornell’s campus. It enhances the dining experience at Cornell with features such as providing the crowdedness of eateries, checking meal swipes and dining money balances, and favoriting dishes. Eatery is available on both [iOS](https://github.com/cuappdev/eatery-blue-ios) and Android platforms. From 900f4b5009e4bb3f80723e582a93dd305c429f56 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 5 Nov 2025 18:01:21 -0500 Subject: [PATCH 049/126] Initial login route refactor --- .../android/eatery/data/NetworkingApi.kt | 45 ++++--------- .../android/eatery/data/models/User.kt | 52 +++++++-------- .../data/repositories/EateryRepository.kt | 7 +- .../data/repositories/UserRepository.kt | 66 +++---------------- .../eatery/ui/components/login/AccountPage.kt | 57 +++++++--------- .../eatery/ui/screens/ProfileScreen.kt | 62 ++++++++--------- .../eatery/ui/viewmodels/LoginViewModel.kt | 63 ++++++------------ 7 files changed, 119 insertions(+), 233 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index ad07a45a..c3c91d6c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -1,43 +1,18 @@ package com.cornellappdev.android.eatery.data -import com.cornellappdev.android.eatery.data.models.AccountsResponse -import com.cornellappdev.android.eatery.data.models.ApiResponse +import com.cornellappdev.android.eatery.data.models.AuthorizedUser import com.cornellappdev.android.eatery.data.models.Eatery -import com.cornellappdev.android.eatery.data.models.Event -import com.cornellappdev.android.eatery.data.models.GetApiAccountsParams -import com.cornellappdev.android.eatery.data.models.GetApiRequestBody import com.cornellappdev.android.eatery.data.models.GetApiResponse -import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryParams -import com.cornellappdev.android.eatery.data.models.GetApiUserParams +import com.cornellappdev.android.eatery.data.models.LoginRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody -import com.cornellappdev.android.eatery.data.models.TransactionsResponse import com.cornellappdev.android.eatery.data.models.User import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path -import retrofit2.http.Url interface NetworkApi { - @POST() - suspend fun fetchUser( - @Url url: String, - @Body body: GetApiRequestBody - ): GetApiResponse - - @POST() - suspend fun fetchAccounts( - @Url url: String, - @Body body: GetApiRequestBody - ): GetApiResponse - - @POST() - suspend fun fetchTransactionHistory( - @Url url: String, - @Body body: GetApiRequestBody - ): GetApiResponse - @GET("/eatery/") suspend fun fetchEateries(): List @@ -47,18 +22,20 @@ interface NetworkApi { @GET("/eatery/simple") suspend fun fetchHomeEateries(): List - @GET("/event") - suspend fun fetchEvents(): ApiResponse> - - @POST("/report/") suspend fun sendReport( @Body report: ReportSendBody ): GetApiResponse - @POST("/user/authorize") + @POST("/user/authorize/") suspend fun authorizeUser( @Header("Authorization") sessionId: String, - @Body user: User - ): ApiResponse + @Body loginRequest: LoginRequest + ): AuthorizedUser + + @POST("/user/accounts/") + suspend fun getUserAccounts( + @Header("Authorization") sessionId: String, + @Body user: AuthorizedUser + ): User } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index 8e00d893..87ffacd9 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -5,43 +5,41 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class User( - @Json(name = "id") val id: String? = null, - @Json(name = "fcmToken") val fcmToken: String? = null, - @Json(name = "deviceId") val deviceId: String? = null, - @Json(name = "pin") val pin: Int? = null, - @Json(name = "favorite_eateries") val favoriteEateries: List? = null, - @Json(name = "brb_balance") val brbBalance: Double? = null, - @Json(name = "city_bucks_balance") val cityBucksBalance: Double? = null, - @Json(name = "laundry_balance") val laundryBalance: Double? = null, - @Json(name = "brb_account_name") val brbAccountName: String? = null, - @Json(name = "city_bucks_account_name") val cityBucksAccountName: String? = null, - @Json(name = "laundry_account_name") val laundryAccountName: String? = null, - @Json(name = "userName") val userName: String? = null, - @Json(name = "firstName") val firstName: String? = null, - @Json(name = "middleName") val middleName: String? = null, - @Json(name = "lastName") val lastName: String? = null, - @Json(name = "email") val email: String? = null, - @Json(name = "phone") val phone: String? = null, + @Json(name = "id") val id: Long = 0, + @Json(name = "device_id") val deviceId: String = "", + @Json(name = "fcm_token") val fcmToken: String = "", + @Json(name = "favorite_eateries") val favoriteEateries: List = emptyList(), + @Json(name = "favorite_items") val favoriteItems: List = emptyList(), + @Json(name = "brb_balance") val brbBalance: Double = 0.0, + @Json(name = "city_bucks_balance") val cityBucksBalance: Double = 0.0, + @Json(name = "laundry_balance") val laundryBalance: Double = 0.0, + @Json(name = "brb_account_name") val brbAccountName: String = "", + @Json(name = "city_bucks_account_name") val cityBucksAccountName: String = "", + @Json(name = "laundry_account_name") val laundryAccountName: String = "", + @Json(name = "userName") val userName: String = "", var accounts: List? = null, var transactions: List? = listOf() ) @JsonClass(generateAdapter = true) -data class AccountsResponse( - @Json(name = "accounts") val accounts: List? = null +data class LoginRequest( + @Json(name = "device_id") val deviceId: String = "", + @Json(name = "fcm_token") val fcmToken: String = "", + @Json(name = "pin") val pin: Int = 0 ) @JsonClass(generateAdapter = true) -data class Account( - @Json(name = "accountDisplayName") val type: AccountType? = null, - @Json(name = "balance") val balance: Double? = null +data class AuthorizedUser( + @Json(name = "id") val id: Long = 0, + @Json(name = "device_id") val deviceId: String = "", + @Json(name = "fcm_token") val fcmToken: String = "", + @Json(name = "pin") val pin: Int = 0 ) @JsonClass(generateAdapter = true) -data class TransactionsResponse( - @Json(name = "totalCount") val totalCount: Int? = null, - @Json(name = "returnCapped") val returnCapped: Boolean? = null, - @Json(name = "transactions") val transactions: List? = null +data class Account( + @Json(name = "accountDisplayName") val type: AccountType? = null, + @Json(name = "balance") val balance: Double? = null ) @JsonClass(generateAdapter = true) @@ -79,6 +77,6 @@ enum class TransactionType(val value: Int) { DEPOSIT(3), SPEND(1), NOOP(0), MISC(2); companion object { - fun fromInt(value: Int) = values().first { it.value == value } + fun fromInt(value: Int) = TransactionType.entries.first { it.value == value } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index 05c0e620..dbfd6604 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -1,9 +1,7 @@ package com.cornellappdev.android.eatery.data.repositories import com.cornellappdev.android.eatery.data.NetworkApi -import com.cornellappdev.android.eatery.data.models.ApiResponse import com.cornellappdev.android.eatery.data.models.Eatery -import com.cornellappdev.android.eatery.data.models.Event import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -28,9 +26,6 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { private suspend fun getHomeEateries(): List = networkApi.fetchHomeEateries() - private suspend fun getAllEvents(): ApiResponse> = - networkApi.fetchEvents() - private val _eateryFlow: MutableStateFlow>> = MutableStateFlow(EateryApiResponse.Pending) @@ -76,7 +71,7 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { try { val eateries = getAllEateries() _eateryFlow.value = EateryApiResponse.Success(eateries) - eateryApiCache.update { map -> + eateryApiCache.update { _ -> eateries.filter { it.id != null } .associate { it.id!! to EateryApiResponse.Success(it) } .withDefault { EateryApiResponse.Error } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 5862f0e9..4d98c2f7 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -1,30 +1,18 @@ package com.cornellappdev.android.eatery.data.repositories -import com.cornellappdev.android.eatery.BuildConfig import com.cornellappdev.android.eatery.data.NetworkApi -import com.cornellappdev.android.eatery.data.models.AccountsResponse -import com.cornellappdev.android.eatery.data.models.ApiResponse -import com.cornellappdev.android.eatery.data.models.GetApiAccountsParams -import com.cornellappdev.android.eatery.data.models.GetApiRequestBody -import com.cornellappdev.android.eatery.data.models.GetApiResponse -import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryParams -import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryQueryCriteria +import com.cornellappdev.android.eatery.data.models.LoginRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody -import com.cornellappdev.android.eatery.data.models.TransactionsResponse import com.cornellappdev.android.eatery.data.models.User -import java.text.SimpleDateFormat -import java.time.Duration -import java.util.Date -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @Singleton class UserRepository @Inject constructor(private val networkApi: NetworkApi) { - suspend fun sendReport(issue: String, report: String, eateryid: Int?): Any = + suspend fun sendReport(issue: String, report: String, eateryID: Int?): Any = networkApi.sendReport( report = ReportSendBody( - eatery = eateryid, + eatery = eateryID, content = "$issue: $report" ) ) @@ -33,49 +21,15 @@ class UserRepository @Inject constructor(private val networkApi: NetworkApi) { sessionId: String, deviceId: String, fcmToken: String - ): ApiResponse { - return networkApi.authorizeUser( + ): User { + val authorizedUser = networkApi.authorizeUser( sessionId = "Bearer $sessionId", - user = User(deviceId = deviceId, pin = 1234, fcmToken = fcmToken) + loginRequest = LoginRequest(deviceId = deviceId, pin = 1234, fcmToken = fcmToken) ) - } - - suspend fun getAccount(sessionId: String, userId: String): GetApiResponse = - networkApi.fetchAccounts( - url = BuildConfig.GET_BACKEND_URL + "commerce", - body = GetApiRequestBody( - version = "1", - method = "retrieveAccountsByUser", - params = GetApiAccountsParams( - sessionId = sessionId, - userId = userId - ) - ) + return networkApi.getUserAccounts( + sessionId = "Bearer $sessionId", + user = authorizedUser ) + } - suspend fun getTransactionHistory( - sessionId: String, - userId: String, - endDate: Date = Date(), - startDate: Date = Date.from( - endDate.toInstant().minus(Duration.ofDays(1460)) - ) - ): GetApiResponse = networkApi.fetchTransactionHistory( - url = BuildConfig.GET_BACKEND_URL + "commerce", - body = GetApiRequestBody( - version = "1", - method = "retrieveTransactionHistory", - params = GetApiTransactionHistoryParams( - paymentSystemType = 0, - sessionId = sessionId, - queryCriteria = GetApiTransactionHistoryQueryCriteria( - endDate = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(endDate), - startDate = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(startDate), - maxReturn = 250, - institutionId = BuildConfig.CORNELL_INSTITUTION_ID, - userId = userId - ) - ) - ) - ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 54037bee..6a5f61a8 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -53,7 +53,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.cornellappdev.android.eatery.R -import com.cornellappdev.android.eatery.data.models.Account import com.cornellappdev.android.eatery.data.models.AccountType import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.ui.components.general.SearchBar @@ -72,8 +71,7 @@ import java.time.format.DateTimeFormatter @Composable fun AccountPage( accountFilter: AccountType, - checkAccount: (AccountType) -> Account?, - checkMealPlan: () -> Account?, + accountTypeBalance: Map, onSettingsClicked: () -> Unit, getTransactionsOfType: (AccountType, String) -> List, updateAccountFilter: (AccountType) -> Unit @@ -220,9 +218,8 @@ fun AccountPage( ) AccountBalanceRow( accountName = "Meal Swipes", - accountType = AccountType.MEALSWIPES, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan + balance = accountTypeBalance[AccountType.MEALSWIPES], + isMealSwipes = true ) Spacer( modifier = Modifier @@ -232,9 +229,8 @@ fun AccountPage( ) AccountBalanceRow( accountName = "Big Red Bucks", - accountType = AccountType.BRBS, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan + balance = accountTypeBalance[AccountType.BRBS], + isMealSwipes = false ) Spacer( modifier = Modifier @@ -244,9 +240,8 @@ fun AccountPage( ) AccountBalanceRow( accountName = "City Bucks", - accountType = AccountType.CITYBUCKS, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan + balance = accountTypeBalance[AccountType.CITYBUCKS], + isMealSwipes = false ) Spacer( modifier = Modifier @@ -256,9 +251,8 @@ fun AccountPage( ) AccountBalanceRow( accountName = "Laundry", - accountType = AccountType.LAUNDRY, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan + balance = accountTypeBalance[AccountType.LAUNDRY], + isMealSwipes = false ) } }) @@ -353,7 +347,7 @@ fun AccountPage( accountFilter, filterText ) - ) { it -> + ) { val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") val outputFormatter = DateTimeFormatter.ofPattern("h:mm a · EEEE, MMMM d") @@ -417,9 +411,8 @@ fun AccountPage( @Composable fun AccountBalanceRow( accountName: String, - accountType: AccountType, - checkAccount: (AccountType) -> Account?, - checkMealPlan: () -> Account? + isMealSwipes: Boolean, + balance: Double? ) { Row( modifier = Modifier.height(50.dp), @@ -430,20 +423,18 @@ fun AccountBalanceRow( text = accountName, style = EateryBlueTypography.button, ) - Text( - modifier = Modifier.weight(1f), - textAlign = TextAlign.Right, - text = if (accountType != AccountType.MEALSWIPES) { - "$" + "%.2f".format( - checkAccount(accountType)?.balance?.toFloat() ?: 0f - ) - } else { - "%.0f".format( - checkMealPlan()?.balance?.toFloat() ?: 0f - ) + " remaining" - }, - style = EateryBlueTypography.button, - ) + if (balance != null) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Right, + text = if (!isMealSwipes) { + "$" + "%.2f".format(balance.toFloat()) + } else { + "%.0f".format(balance.toFloat()) + " remaining" + }, + style = EateryBlueTypography.button, + ) + } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 65d8c9d5..2f2cec7f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.tooling.preview.Preview -import com.cornellappdev.android.eatery.data.models.Account import com.cornellappdev.android.eatery.data.models.AccountType import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.ui.components.login.AccountPage @@ -23,7 +22,8 @@ fun ProfileScreen( ) { val state = loginViewModel.state.collectAsState().value ProfileScreenContent( - state, + isLoginState = state is LoginViewModel.State.Login, + accountTypeBalance = state.getBalanceMap(), loading = state is LoginViewModel.State.Login && state.loading, onLoginPressed = loginViewModel::onLoginPressed, onSuccess = loginViewModel::onLoginWebViewSuccess, @@ -31,8 +31,6 @@ fun ProfileScreen( onBackClick = onBackClick, onModalHidden = loginViewModel::onLoginExited, accountFilter = if (state is LoginViewModel.State.Account) state.accountFilter else AccountType.BRBS, - checkAccount = loginViewModel::checkAccount, - checkMealPlan = loginViewModel::checkMealPlan, onSettingsClicked = onSettingsClicked, getTransactionsOfType = loginViewModel::getTransactionsOfType, updateAccountFilter = loginViewModel::updateAccountFilter @@ -41,7 +39,8 @@ fun ProfileScreen( @Composable private fun ProfileScreenContent( - state: LoginViewModel.State, + isLoginState: Boolean, + accountTypeBalance: Map, loading: Boolean, onLoginPressed: () -> Unit, onSuccess: (String) -> Unit, @@ -49,48 +48,47 @@ private fun ProfileScreenContent( onBackClick: () -> Unit, onModalHidden: () -> Unit, accountFilter: AccountType, - checkAccount: (AccountType) -> Account?, - checkMealPlan: () -> Account?, onSettingsClicked: () -> Unit, getTransactionsOfType: (AccountType, String) -> List, updateAccountFilter: (AccountType) -> Unit ) { - when (state) { - is LoginViewModel.State.Login -> { - LoginPage( - loading = loading, - onLoginPressed = onLoginPressed, - onSuccess = onSuccess, - webViewEnabled = webViewEnabled, - onBackClick = onBackClick, - onModalHidden = onModalHidden - ) - } - - is LoginViewModel.State.Account -> { - AccountPage( - accountFilter = accountFilter, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan, - onSettingsClicked = onSettingsClicked, - getTransactionsOfType = getTransactionsOfType, - updateAccountFilter = updateAccountFilter - ) - } + if (isLoginState) { + LoginPage( + loading = loading, + onLoginPressed = onLoginPressed, + onSuccess = onSuccess, + webViewEnabled = webViewEnabled, + onBackClick = onBackClick, + onModalHidden = onModalHidden + ) + } else { + AccountPage( + accountFilter = accountFilter, + accountTypeBalance = accountTypeBalance, + onSettingsClicked = onSettingsClicked, + getTransactionsOfType = getTransactionsOfType, + updateAccountFilter = updateAccountFilter + ) } } @Preview @Composable private fun ProfileLoginScreenPreview() = EateryPreview { - val state = LoginViewModel.State.Login( + LoginViewModel.State.Login( netID = "aaa00", password = "myVeryLongPassword", failureMessage = null, loading = false ) ProfileScreenContent( - state = state, + isLoginState = false, + accountTypeBalance = mapOf( + AccountType.BRBS to 1234.56, + AccountType.CITYBUCKS to 78.90, + AccountType.LAUNDRY to 12.34, + AccountType.MEALSWIPES to 4.20 + ), loading = false, onLoginPressed = {}, onSuccess = {}, @@ -98,8 +96,6 @@ private fun ProfileLoginScreenPreview() = EateryPreview { onBackClick = {}, onModalHidden = {}, accountFilter = AccountType.BRBS, - checkAccount = { null }, - checkMealPlan = { null }, onSettingsClicked = {}, getTransactionsOfType = { _, _ -> emptyList() }, updateAccountFilter = {}, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index b9c393e5..adfc2989 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -1,8 +1,8 @@ package com.cornellappdev.android.eatery.ui.viewmodels +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.cornellappdev.android.eatery.data.models.Account import com.cornellappdev.android.eatery.data.models.AccountType import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.data.models.User @@ -41,6 +41,17 @@ class LoginViewModel @Inject constructor( var query: String, // Search bar query. var accountFilter: AccountType // Search bar filter. ) : State() + + fun getBalanceMap(): Map { + if (this !is Account) return mapOf() + val balanceMap = mutableMapOf() + this.user.accounts?.forEach { account -> + if (account.type != null) { + balanceMap[account.type] = account.balance + } + } + return balanceMap + } } private var _state = MutableStateFlow( @@ -54,19 +65,6 @@ class LoginViewModel @Inject constructor( // Convert the state to a flow that can be updated by screens that use the LoginViewModel val state = _state.asStateFlow() - // List of all available meal plans - val mealPlanList = mutableListOf( - AccountType.FLEX, - AccountType.BEAR_TRADITIONAL, - AccountType.BEAR_CHOICE, - AccountType.BEAR_BASIC, - AccountType.UNLIMITED, - AccountType.HOUSE_AFFILIATE, - AccountType.HOUSE_MEALPLAN, - AccountType.JUST_BUCKS, - AccountType.OFF_CAMPUS - ) - init { getSavedLoginInfo() } @@ -75,25 +73,6 @@ class LoginViewModel @Inject constructor( _state.value = State.Login() } - // Check what the meal plan is against our list of meal plans - fun checkMealPlan(): Account? { - if (_state.value !is State.Account || CurrentUser.user == null) return null - var currAccount: Account? = null - CurrentUser.user!!.accounts!!.forEach { - if (mealPlanList.contains(it.type)) { - currAccount = it - } - } - return currAccount - } - - fun checkAccount(accountType: AccountType): Account? { - if (_state.value !is State.Account || CurrentUser.user == null) return null - return CurrentUser.user!!.accounts!!.find { - it.type == accountType - } - } - fun updateAccountFilter(newAccountType: AccountType) { val currState = _state.value if (currState !is State.Account) return @@ -158,22 +137,17 @@ class LoginViewModel @Inject constructor( * Fetches user data given [sessionId] and updates the state and user preferences. */ private fun getUser(sessionId: String) = viewModelScope.launch { + val currState = _state.value + if (userPreferencesRepository.getDeviceId() == null) { + userPreferencesRepository.setDeviceId(UUID.randomUUID()) + } try { - val currState = _state.value - if (userPreferencesRepository.getDeviceId() == null) { - userPreferencesRepository.setDeviceId(UUID.randomUUID()) - } val fcmToken = com.google.firebase.messaging.FirebaseMessaging.getInstance().token.await() val deviceId = userPreferencesRepository.getDeviceId()!! - val user = userRepository.getUser(sessionId, deviceId, fcmToken).data!! - val account = userRepository.getAccount(sessionId, user.id!!).response!!.accounts - val transactions = - userRepository.getTransactionHistory(sessionId, user.id).response!!.transactions - user.accounts = account - user.transactions = transactions + Log.d("debug", "sessionId: $sessionId, deviceId: $deviceId, fcmToken: $fcmToken") + val user = userRepository.getUser(sessionId, deviceId, fcmToken) CurrentUser.user = user - if (currState is State.Login) { userPreferencesRepository.saveLoginInfo(sessionId, currState.password) userPreferencesRepository.setIsLoggedIn(true) @@ -185,6 +159,7 @@ class LoginViewModel @Inject constructor( ) _state.value = newState } catch (e: Exception) { + // todo - error state val currState = _state.value if (currState is State.Login) { val newState = State.Login( From 1e1529dfb585fcefa86babb903baa8ffff64aa94 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 8 Nov 2025 12:02:08 -0500 Subject: [PATCH 050/126] Remove netID from settings --- .../com/cornellappdev/android/eatery/data/models/User.kt | 1 - .../android/eatery/ui/screens/SettingsScreen.kt | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index 87ffacd9..c1ce3be4 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -16,7 +16,6 @@ data class User( @Json(name = "brb_account_name") val brbAccountName: String = "", @Json(name = "city_bucks_account_name") val cityBucksAccountName: String = "", @Json(name = "laundry_account_name") val laundryAccountName: String = "", - @Json(name = "userName") val userName: String = "", var accounts: List? = null, var transactions: List? = listOf() ) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt index dafd8397..865c3ea4 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt @@ -256,14 +256,9 @@ fun SettingsScreen( modifier = Modifier .fillMaxWidth() .padding(bottom = 34.dp), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "Logged in as ${state.user.userName!!.substringBefore('@')}", - style = EateryBlueTypography.h5, - color = GrayFive - ) Button( onClick = { loginViewModel.onLogoutPressed() From 44a3e150f8a24f85878f87cf650aa9e96a926c00 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 8 Nov 2025 21:33:53 -0500 Subject: [PATCH 051/126] Implement transactions retrieval plus internal refactoring --- .../android/eatery/data/MoshiAdapters.kt | 10 +- .../android/eatery/data/NetworkingApi.kt | 13 + .../eatery/data/models/AccountBalances.kt | 8 + .../android/eatery/data/models/ApiModels.kt | 44 +- .../android/eatery/data/models/Eatery.kt | 14 +- .../android/eatery/data/models/User.kt | 71 +- .../data/repositories/CoilRepository.kt | 3 +- .../data/repositories/UserRepository.kt | 27 +- .../eatery/ui/components/login/AccountPage.kt | 687 ++++++++++-------- .../eatery/ui/screens/AccountScreen.kt | 13 - .../eatery/ui/screens/ProfileScreen.kt | 30 +- .../android/eatery/ui/theme/Color.kt | 1 + .../ui/viewmodels/CompareMenusBotViewModel.kt | 6 +- .../eatery/ui/viewmodels/LoginViewModel.kt | 70 +- .../android/eatery/util/Constants.kt | 2 +- 15 files changed, 553 insertions(+), 446 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/data/models/AccountBalances.kt delete mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/screens/AccountScreen.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt index 09699820..398cdea5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt @@ -1,3 +1,5 @@ +@file:Suppress("unused") + package com.cornellappdev.android.eatery.data import com.cornellappdev.android.eatery.data.models.AccountType @@ -116,7 +118,7 @@ class AccountTypeAdapter { "brb" } - AccountType.CITYBUCKS -> { + AccountType.CITY_BUCKS -> { "city bucks" } @@ -124,10 +126,6 @@ class AccountTypeAdapter { "laundry" } - AccountType.MEALSWIPES -> { - "meal plan" - } - else -> { "other" } @@ -155,7 +153,7 @@ class AccountTypeAdapter { return if (accountName.contains("brb", ignoreCase = true)) { AccountType.BRBS } else if (accountName.contains("city bucks", ignoreCase = true)) { - AccountType.CITYBUCKS + AccountType.CITY_BUCKS } else if (accountName.contains("laundry", ignoreCase = true)) { AccountType.LAUNDRY } else { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index c3c91d6c..3db22dbe 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -1,10 +1,12 @@ package com.cornellappdev.android.eatery.data +import com.cornellappdev.android.eatery.data.models.Accounts import com.cornellappdev.android.eatery.data.models.AuthorizedUser import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.GetApiResponse import com.cornellappdev.android.eatery.data.models.LoginRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody +import com.cornellappdev.android.eatery.data.models.Transactions import com.cornellappdev.android.eatery.data.models.User import retrofit2.http.Body import retrofit2.http.GET @@ -37,5 +39,16 @@ interface NetworkApi { suspend fun getUserAccounts( @Header("Authorization") sessionId: String, @Body user: AuthorizedUser + ): Accounts + + @POST("/user/transactions/") + suspend fun getUserTransactions( + @Header("Authorization") sessionId: String, + @Body user: AuthorizedUser + ): Transactions + + @GET("/user/{id}/") + suspend fun getUserData( + @Path("id") id: Long ): User } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/AccountBalances.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/AccountBalances.kt new file mode 100644 index 00000000..c5a65817 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/AccountBalances.kt @@ -0,0 +1,8 @@ +package com.cornellappdev.android.eatery.data.models + +data class AccountBalances( + val brbBalance: Double? = null, + val cityBucksBalance: Double? = null, + val laundryBalance: Double? = null, + val mealSwipes: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/ApiModels.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/ApiModels.kt index fec2dba2..03a29e04 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/ApiModels.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/ApiModels.kt @@ -3,55 +3,15 @@ package com.cornellappdev.android.eatery.data.models import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -@JsonClass(generateAdapter = true) -data class ApiResponse( - @Json(name = "success") val success: Boolean, - @Json(name = "data") val data: T? = null, - @Json(name = "error") val error: String? = null -) - +// todo - update these @JsonClass(generateAdapter = true) data class GetApiResponse( @Json(name = "response") val response: T? = null, @Json(name = "exception") val exception: String? = null ) -@JsonClass(generateAdapter = true) -data class GetApiRequestBody( - val version: String, - val method: String, - val params: T -) - -@JsonClass(generateAdapter = true) -data class GetApiUserParams( - val sessionId: String -) - -@JsonClass(generateAdapter = true) -data class GetApiAccountsParams( - val sessionId: String, - val userId: String -) - -@JsonClass(generateAdapter = true) -data class GetApiTransactionHistoryParams( - val paymentSystemType: Int, - val sessionId: String, - val queryCriteria: GetApiTransactionHistoryQueryCriteria -) - -@JsonClass(generateAdapter = true) -data class GetApiTransactionHistoryQueryCriteria( - val endDate: String, - val institutionId: String, - val maxReturn: Int, - val startDate: String, - val userId: String -) - @JsonClass(generateAdapter = true) data class ReportSendBody( @Json(name = "eatery") val eatery: Int?, @Json(name = "content") val content: String -) +) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt index 5a4aaf4d..0b25652f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt @@ -40,6 +40,7 @@ data class Eatery( @Json(name = "wait_times") val waitTimes: List? = null, @Json(name = "alerts") val alerts: List? = null, ) { + // todo - investigate unused methods fun getWalkTimes(): Int? { val currentLocation = LocationHandler.currentLocation.value val results = floatArrayOf(0f) @@ -168,7 +169,7 @@ data class Eatery( * for louies, it returns [("General",some string duration)] * Note, string duration are in the format "11:00 AM - 2:30 PM" */ - fun getTypeMeal(currSelectedDay: DayOfWeek): List>? { + fun getTypeMeal(currSelectedDay: DayOfWeek): List> { val timeFormatter = DateTimeFormatter.ofPattern("h:mm a") val uniqueMeals = LinkedHashMap() @@ -207,7 +208,6 @@ data class Eatery( fun getSelectedDayMeal(meal: MealFilter, day: Int): List? { var currentDay = LocalDate.now() currentDay = currentDay.plusDays(day.toLong()) -// Log.d(name, events?.filter { currentDay.dayOfYear == it.startTime?.dayOfYear }.toString()) return events?.filter { event -> currentDay.dayOfYear == event.startTime?.dayOfYear && meal.text.contains(event.description) } @@ -297,14 +297,12 @@ data class Eatery( * e.g. For Oken, {Monday -> ["11:00 AM - 2:30 PM", "4:30 PM - 9:00 PM"], Sunday -> "Closed"} */ private fun operatingHours(): Map> { - var dailyHours = mutableMapOf>() + val dailyHours = mutableMapOf>() events?.forEach { event -> val dayOfWeek = event.startTime?.dayOfWeek val openTime = event.startTime?.format(DateTimeFormatter.ofPattern("h:mm a")) val closeTime = event.endTime?.format(DateTimeFormatter.ofPattern("h:mm a")) -// Log.d("event", event.toString()) - val timeString = "$openTime - $closeTime" if (dayOfWeek != null && dailyHours[dayOfWeek]?.none { it.contains(timeString) } != false) { @@ -312,7 +310,7 @@ data class Eatery( } } - DayOfWeek.values().forEach { dayOfWeek -> + DayOfWeek.entries.forEach { dayOfWeek -> dailyHours.computeIfAbsent(dayOfWeek) { mutableListOf("Closed") } } @@ -329,7 +327,7 @@ data class Eatery( * day(s) mapped to opening hours. */ fun formatOperatingHours(): List>> { - var dailyHours = operatingHours() + val dailyHours = operatingHours() val groupedHours = dailyHours.entries.groupBy({ it.value }, { it.key }) @@ -390,7 +388,7 @@ data class Eatery( } } - var formattedHoursList = formattedHours.toList().sortedBy { entry -> + val formattedHoursList = formattedHours.toList().sortedBy { entry -> val firstDay = entry.first.split(" to ", " ", limit = 2).first() dayOrder[firstDay] ?: Int.MAX_VALUE } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index c1ce3be4..7f0d8a54 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -1,3 +1,5 @@ +@file:Suppress("AddExplicitTargetToParameterAnnotation") + package com.cornellappdev.android.eatery.data.models import com.squareup.moshi.Json @@ -5,19 +7,13 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class User( - @Json(name = "id") val id: Long = 0, - @Json(name = "device_id") val deviceId: String = "", - @Json(name = "fcm_token") val fcmToken: String = "", @Json(name = "favorite_eateries") val favoriteEateries: List = emptyList(), @Json(name = "favorite_items") val favoriteItems: List = emptyList(), @Json(name = "brb_balance") val brbBalance: Double = 0.0, @Json(name = "city_bucks_balance") val cityBucksBalance: Double = 0.0, @Json(name = "laundry_balance") val laundryBalance: Double = 0.0, - @Json(name = "brb_account_name") val brbAccountName: String = "", - @Json(name = "city_bucks_account_name") val cityBucksAccountName: String = "", - @Json(name = "laundry_account_name") val laundryAccountName: String = "", - var accounts: List? = null, - var transactions: List? = listOf() + @Json(name = "transactions") val transactions: List? = listOf(), + @Json(name = "meal_swipes") val mealSwipes: Int? = null // todo - backend should make this ) @JsonClass(generateAdapter = true) @@ -35,41 +31,70 @@ data class AuthorizedUser( @Json(name = "pin") val pin: Int = 0 ) +@JsonClass(generateAdapter = true) +data class Accounts( + @Json(name = "brb") val brbBalance: Account? = null, + @Json(name = "city_bucks") val cityBucksBalance: Account? = null, + @Json(name = "laundry") val laundryBalance: Account? = null +) + @JsonClass(generateAdapter = true) data class Account( - @Json(name = "accountDisplayName") val type: AccountType? = null, - @Json(name = "balance") val balance: Double? = null + @Json(name = "name") val name: String = "", + @Json(name = "balance") val balance: Double = 0.0 +) + +@JsonClass(generateAdapter = true) +data class Transactions( + @Json(name = "transactions") val transactions: List = emptyList() ) @JsonClass(generateAdapter = true) data class Transaction( - @Json(name = "transactionId") val id: String? = null, - @Json(name = "amount") val amount: Double? = null, - @Json(name = "resultingBalance") val resultingBalance: Double? = null, - @Json(name = "postedDate") val date: String? = null, - // make this TransactionType later - @Json(name = "transactionType") val transactionType: Int? = null, - @Json(name = "accountName") val accountType: AccountType? = null, - @Json(name = "locationName") val location: String? = null, + @Json(name = "amount") val amount: Double = 0.0, + @Json(name = "accountName") val accountType: AccountType = AccountType.OTHER, + @Json(name = "date") val date: String = "", + @Json(name = "location") val location: String = "", + @Json(name = "transactionType") val transactionType: TransactionType = TransactionType.NOOP // todo - backend should give this ) +/** + * Categories for transactions used for filtering. More general than AccountType. + */ +enum class TransactionAccountType { + MEAL_SWIPES, + BRBS, + CITY_BUCKS, + LAUNDRY +} + +/** + * Specific account types as they show up in the backend. + */ enum class AccountType { - // MEALSWIPES is used for transaction history filtering, only. For anything else, use the actual - // meal plan types in the block below (OFF_CAMPUS, BEAR_TRADITIONAL, etc.). LAUNDRY, - MEALSWIPES, BRBS, - CITYBUCKS, + CITY_BUCKS, OFF_CAMPUS, BEAR_TRADITIONAL, UNLIMITED, BEAR_BASIC, BEAR_CHOICE, - HOUSE_MEALPLAN, + HOUSE_MEAL_PLAN, HOUSE_AFFILIATE, FLEX, JUST_BUCKS, OTHER + // todo - are there more? +} + +fun AccountType.toTransactionAccountType(): TransactionAccountType { + return when (this) { + AccountType.BRBS -> TransactionAccountType.BRBS + AccountType.CITY_BUCKS -> TransactionAccountType.CITY_BUCKS + AccountType.LAUNDRY -> TransactionAccountType.LAUNDRY + else -> TransactionAccountType.MEAL_SWIPES + } } enum class TransactionType(val value: Int) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/CoilRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/CoilRepository.kt index 2e05a43a..f2c7b8bb 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/CoilRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/CoilRepository.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.drawable.toBitmap import coil.imageLoader import coil.request.ImageRequest -import com.cornellappdev.android.eatery.data.models.ApiResponse import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,7 +21,7 @@ object CoilRepository { mutableMapOf() /** - * Returns a [MutableState] containing an [ApiResponse] corresponding to a loading or loaded + * Returns a [MutableState] containing an [EateryApiResponse] corresponding to a loading or loaded * image bitmap for loading the input [imageUrl]. If the image previously resulted in an error, * calling this function will attempt to re-load. * diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 4d98c2f7..e0cf2a96 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -9,6 +9,11 @@ import javax.inject.Singleton @Singleton class UserRepository @Inject constructor(private val networkApi: NetworkApi) { + /** + * The currently loaded user. Null if no user is logged in. + */ + var loadedUser: User? = null + suspend fun sendReport(issue: String, report: String, eateryID: Int?): Any = networkApi.sendReport( report = ReportSendBody( @@ -17,19 +22,31 @@ class UserRepository @Inject constructor(private val networkApi: NetworkApi) { ) ) + /** + * Fetches the user from backend. + */ suspend fun getUser( sessionId: String, deviceId: String, fcmToken: String ): User { + val bearerToken = "Bearer $sessionId" val authorizedUser = networkApi.authorizeUser( - sessionId = "Bearer $sessionId", + sessionId = bearerToken, loginRequest = LoginRequest(deviceId = deviceId, pin = 1234, fcmToken = fcmToken) ) - return networkApi.getUserAccounts( - sessionId = "Bearer $sessionId", + // load accounts in case needed + networkApi.getUserAccounts( + sessionId = bearerToken, user = authorizedUser ) + val transactions = networkApi.getUserTransactions( + sessionId = bearerToken, + user = authorizedUser + ).transactions + val userWithData = networkApi.getUserData( + id = authorizedUser.id + ).copy(transactions = transactions) + return userWithData } - -} +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 6a5f61a8..a931dd9f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,7 +46,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -53,16 +53,24 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.cornellappdev.android.eatery.R -import com.cornellappdev.android.eatery.data.models.AccountType +import com.cornellappdev.android.eatery.data.models.AccountBalances import com.cornellappdev.android.eatery.data.models.Transaction +import com.cornellappdev.android.eatery.data.models.TransactionAccountType +import com.cornellappdev.android.eatery.data.models.TransactionType import com.cornellappdev.android.eatery.ui.components.general.SearchBar import com.cornellappdev.android.eatery.ui.components.home.BottomSheetContent +import com.cornellappdev.android.eatery.ui.theme.Black import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography +import com.cornellappdev.android.eatery.ui.theme.GrayFive import com.cornellappdev.android.eatery.ui.theme.GrayZero +import com.cornellappdev.android.eatery.ui.theme.Green +import com.cornellappdev.android.eatery.ui.theme.Red +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import kotlin.math.abs @OptIn( ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, @@ -70,11 +78,11 @@ import java.time.format.DateTimeFormatter ) @Composable fun AccountPage( - accountFilter: AccountType, - accountTypeBalance: Map, + accountFilter: TransactionAccountType, + accountTypeBalance: AccountBalances, onSettingsClicked: () -> Unit, - getTransactionsOfType: (AccountType, String) -> List, - updateAccountFilter: (AccountType) -> Unit + getTransactionsOfType: (TransactionAccountType, String) -> List, + updateAccountFilter: (TransactionAccountType) -> Unit ) { var filterText by remember { mutableStateOf("") } val modalBottomSheetState = @@ -89,21 +97,21 @@ fun AccountPage( sheetContent = { when (sheetContent) { BottomSheetContent.ACCOUNT_TYPE -> { - AccountTypesAvailable( + AccountTypesSelector( selectedPaymentMethod = listOf( - AccountType.MEALSWIPES, - AccountType.BRBS, - AccountType.CITYBUCKS, - AccountType.LAUNDRY + TransactionAccountType.MEAL_SWIPES, + TransactionAccountType.BRBS, + TransactionAccountType.CITY_BUCKS, + TransactionAccountType.LAUNDRY ), accountFilter = accountFilter, hide = { coroutineScope.launch { modalBottomSheetState.hide() } - }) { - updateAccountFilter(it) - } + }, + onSubmit = updateAccountFilter + ) } else -> {} @@ -117,302 +125,393 @@ fun AccountPage( ), sheetElevation = 8.dp ) { - val innerListState = rememberLazyListState() - val isFirstVisible = - remember { derivedStateOf { innerListState.firstVisibleItemIndex > 1 } } + AccountPageContent( + onSettingsClicked, + accountTypeBalance, + accountFilter, + coroutineScope, + showBottomSheet = modalBottomSheetState::show, + filterText, + setFilterText = { filterText = it }, + getTransactionsOfType, + setSheetContent = { sheetContent = it }, + ) + } +} - Column( - modifier = Modifier - .fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(EateryBlue) - .then(Modifier.statusBarsPadding()) - .padding(bottom = 7.dp), - ) { - AnimatedContent( - targetState = isFirstVisible.value - ) { isFirstVisible -> - if (isFirstVisible) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(color = EateryBlue) - .padding(top = 12.dp) - ) { - Text( - modifier = Modifier.align(Alignment.Center), - textAlign = TextAlign.Center, - text = "Account", - color = Color.White, - style = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 20.sp - ) +@Composable +@OptIn( + ExperimentalMaterialApi::class, + ExperimentalFoundationApi::class, + ExperimentalAnimationApi::class +) +private fun AccountPageContent( + onSettingsClicked: () -> Unit, + accountTypeBalance: AccountBalances, + accountFilter: TransactionAccountType, + coroutineScope: CoroutineScope, + showBottomSheet: suspend () -> Unit, + filterText: String, + setFilterText: (String) -> Unit, + getTransactionsOfType: (TransactionAccountType, String) -> List, + setSheetContent: (BottomSheetContent) -> Unit +) { + val innerListState = rememberLazyListState() + val isFirstVisible = + remember { derivedStateOf { innerListState.firstVisibleItemIndex > 1 } } + Column( + modifier = Modifier + .fillMaxWidth() + ) { + AccountPageHeader(isFirstVisible, onSettingsClicked) + LazyColumn(state = innerListState) { + item { + (Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Column { + Text( + text = "Meal Plan", + style = EateryBlueTypography.h4, + modifier = Modifier.padding(top = 16.dp) + ) + accountTypeBalance.mealSwipes?.let { + AccountBalanceRow( + accountName = "Meal Swipes", + swipes = it ) - - IconButton( - modifier = Modifier.align(Alignment.CenterEnd), - onClick = { - onSettingsClicked() - } - ) { - Icon( - modifier = Modifier.size(28.dp), - imageVector = Icons.Outlined.Settings, - contentDescription = Icons.Outlined.Settings.name, - tint = Color.White - ) - } } - } else { - Column( + Spacer( modifier = Modifier .fillMaxWidth() - .background(color = EateryBlue) - .then(Modifier.statusBarsPadding()) - .padding(bottom = 7.dp), - ) { - IconButton( - modifier = Modifier - .padding(end = 16.dp) - .align(Alignment.End) - .size(32.dp) - .statusBarsPadding(), - onClick = { onSettingsClicked() }) { - Icon( - modifier = Modifier.size(28.dp), - imageVector = Icons.Outlined.Settings, - contentDescription = Icons.Outlined.Settings.name, - tint = Color.White - ) - } - Column( - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 24.dp - ) - ) { - Text( - text = "Account", - color = Color.White, - style = EateryBlueTypography.h2 - ) - } - } - } - } - } - LazyColumn(state = innerListState) { - item { - (Column(modifier = Modifier.padding(horizontal = 16.dp)) { - Column { - Text( - text = "Meal Plan", - style = EateryBlueTypography.h4, - modifier = Modifier.padding(top = 16.dp) - ) - AccountBalanceRow( - accountName = "Meal Swipes", - balance = accountTypeBalance[AccountType.MEALSWIPES], - isMealSwipes = true - ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) + .height(1.dp) + .background(GrayZero, CircleShape) + ) + accountTypeBalance.brbBalance?.let { AccountBalanceRow( accountName = "Big Red Bucks", - balance = accountTypeBalance[AccountType.BRBS], - isMealSwipes = false - ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) - AccountBalanceRow( - accountName = "City Bucks", - balance = accountTypeBalance[AccountType.CITYBUCKS], - isMealSwipes = false - ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) - AccountBalanceRow( - accountName = "Laundry", - balance = accountTypeBalance[AccountType.LAUNDRY], - isMealSwipes = false + balance = it ) } - }) - } - - item { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(16.dp) - .background(GrayZero) - ) - } - - stickyHeader { - Column( - modifier = Modifier - .background(color = Color.White) - ) { - Row( - modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = when (accountFilter.name) { - "MEALSWIPES" -> "Meal Swipes" - "BRBS" -> "Big Red Bucks" - "LAUNDRY" -> "Laundry" - "CITYBUCKS" -> "City Bucks" - else -> "Account Type" - }, - style = EateryBlueTypography.h4, - - ) - } - IconButton( - onClick = { - sheetContent = BottomSheetContent.ACCOUNT_TYPE - coroutineScope.launch { - modalBottomSheetState.show() - } - }, - modifier = Modifier - .padding(start = 8.dp, top = 8.dp, bottom = 8.dp) - .background(color = GrayZero, shape = CircleShape) - ) { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "Change Account Type", - modifier = Modifier - .size(26.dp) - ) - } - } - SearchBar( - searchText = filterText, - onSearchTextChange = { filterText = it }, - modifier = Modifier.padding(bottom = 12.dp, start = 16.dp, end = 16.dp), - placeholderText = "Search for transactions...", - onCancelClicked = { - filterText = "" - } - ) Spacer( modifier = Modifier .fillMaxWidth() .height(1.dp) - .padding(horizontal = 16.dp) .background(GrayZero, CircleShape) ) - Text( - text = "Past 30 Days", - modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), - style = EateryBlueTypography.h5 - ) + accountTypeBalance.cityBucksBalance?.let { + AccountBalanceRow( + accountName = "City Bucks", + balance = it + ) + } Spacer( modifier = Modifier .fillMaxWidth() .height(1.dp) - .padding(horizontal = 16.dp) .background(GrayZero, CircleShape) ) + accountTypeBalance.laundryBalance?.let { + AccountBalanceRow( + accountName = "Laundry", + balance = it + ) + } + } + }) + } + item { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + .background(GrayZero) + ) + } + + stickyHeader { + TransactionsHeader( + accountFilter, + setSheetContent, + coroutineScope, + showBottomSheet, + filterText, + setFilterText + ) + } + items( + getTransactionsOfType( + accountFilter, + filterText + ) + ) { + TransactionRow( + transaction = it, + isMealSwipes = accountFilter == TransactionAccountType.MEAL_SWIPES + ) + } + } + } +} +@Composable +private fun TransactionsHeader( + accountFilter: TransactionAccountType, + setSheetContent: (BottomSheetContent) -> Unit, + coroutineScope: CoroutineScope, + showBottomSheet: suspend () -> Unit, + filterText: String, + setFilterText: ((String) -> Unit) +) { + Column( + modifier = Modifier + .background(color = Color.White) + ) { + Row( + modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = when (accountFilter) { + TransactionAccountType.MEAL_SWIPES -> "Meal Swipes" + TransactionAccountType.BRBS -> "Big Red Bucks" + TransactionAccountType.LAUNDRY -> "Laundry" + TransactionAccountType.CITY_BUCKS -> "City Bucks" + }, + style = EateryBlueTypography.h4 + ) + } + IconButton( + onClick = { + setSheetContent(BottomSheetContent.ACCOUNT_TYPE) + coroutineScope.launch { + showBottomSheet() } - } - items( - getTransactionsOfType( - accountFilter, - filterText + }, + modifier = Modifier + .padding(start = 8.dp, top = 8.dp, bottom = 8.dp) + .background(color = GrayZero, shape = CircleShape) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Change Account Type", + modifier = Modifier + .size(26.dp) + ) + } + } + SearchBar( + searchText = filterText, + onSearchTextChange = setFilterText, + modifier = Modifier.padding(bottom = 12.dp, start = 16.dp, end = 16.dp), + placeholderText = "Search for transactions...", + onCancelClicked = { setFilterText("") } + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .padding(horizontal = 16.dp) + .background(GrayZero, CircleShape) + ) + Text( + text = "Past 30 Days", + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + style = EateryBlueTypography.h5 + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .padding(horizontal = 16.dp) + .background(GrayZero, CircleShape) + ) + } +} + +@Composable +@OptIn(ExperimentalAnimationApi::class) +private fun AccountPageHeader( + isFirstVisible: State, + onSettingsClicked: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(EateryBlue) + .then(Modifier.statusBarsPadding()) + .padding(bottom = 7.dp), + ) { + AnimatedContent( + targetState = isFirstVisible.value + ) { isFirstVisible -> + if (isFirstVisible) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(color = EateryBlue) + .padding(top = 12.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + textAlign = TextAlign.Center, + text = "Account", + color = Color.White, + style = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp + ) ) + + IconButton( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = onSettingsClicked + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = Icons.Outlined.Settings, + contentDescription = Icons.Outlined.Settings.name, + tint = Color.White + ) + } + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = EateryBlue) + .then(Modifier.statusBarsPadding()) + .padding(bottom = 7.dp), ) { - val inputFormatter = - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") - val outputFormatter = DateTimeFormatter.ofPattern("h:mm a · EEEE, MMMM d") - val dateTime = LocalDateTime.parse(it.date, inputFormatter) - Row( + IconButton( modifier = Modifier - .height(64.dp) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(end = 16.dp) + .align(Alignment.End) + .size(32.dp) + .statusBarsPadding(), + onClick = { onSettingsClicked() }) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = Icons.Outlined.Settings, + contentDescription = Icons.Outlined.Settings.name, + tint = Color.White + ) + } + Column( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 24.dp + ) ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = "${it.location}", style = EateryBlueTypography.button) - Text( - text = outputFormatter.format(dateTime), - style = EateryBlueTypography.subtitle2 - ) - } - var amtColor by remember { mutableStateOf(Color.Unspecified) } - var amtString by remember { mutableStateOf("$0.00") } - when { - it.transactionType == 3 -> { - amtString = "+$%.2f".format(it.amount) - amtColor = - Color(LocalContext.current.resources.getColor(R.color.green)) - } - - it.amount?.toInt() == 0 -> { - amtString = "$0.00" - amtColor = Color.Black - } - - else -> { - amtString = "-$%.2f".format(it.amount) - amtColor = - Color(LocalContext.current.resources.getColor(R.color.red)) - } - } Text( - text = amtString, - modifier = Modifier.weight(0.2f), - color = amtColor, - textAlign = TextAlign.Right, - style = EateryBlueTypography.button, + text = "Account", + color = Color.White, + style = EateryBlueTypography.h2 ) - } - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) } } } + } +} + +@Composable +private fun TransactionRow(transaction: Transaction, isMealSwipes: Boolean) { + val dateText = FormatDate(transaction.date) + Row( + modifier = Modifier + .height(64.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = transaction.location, style = EateryBlueTypography.button) + Text( + text = dateText, + style = EateryBlueTypography.subtitle2, + color = GrayFive + ) + } + var amtColor by remember { mutableStateOf(Color.Unspecified) } + var amtString by remember { mutableStateOf("$0.00") } + when { + transaction.transactionType == TransactionType.DEPOSIT -> { + amtString = "+$%.2f".format(transaction.amount) + amtColor = Green + } + + transaction.amount.epsilonEqual(0.0) -> { + amtString = "$0.00" + amtColor = Black + } + + else -> { + amtString = if (isMealSwipes) { + val numSwipes = transaction.amount.toInt() + "-$numSwipes swipe" + (if (numSwipes > 1) "s" else "") + } else { + "-$%.2f".format(transaction.amount) + } + amtColor = Red + } + } + Text( + text = amtString, + modifier = Modifier.weight(0.2f), + color = amtColor, + textAlign = TextAlign.Right, + style = EateryBlueTypography.button, + ) } + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(GrayZero, CircleShape) + ) } +@Composable +private fun FormatDate(dateString: String): String { + val inputFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") + val outputFormatter = DateTimeFormatter.ofPattern("h:mm a · EEEE, MMMM d") + val dateTime = LocalDateTime.parse(dateString, inputFormatter) + val dateText = outputFormatter.format(dateTime) + return dateText ?: "" +} + +private fun Double.epsilonEqual(other: Double): Boolean { + val epsilon = 0.00001 + return abs(this - other) < epsilon +} + + +@Composable +fun AccountBalanceRow( + accountName: String, + balance: Double, +) { + AccountRow(accountName, "$" + "%.2f".format(balance)) +} @Composable fun AccountBalanceRow( accountName: String, - isMealSwipes: Boolean, - balance: Double? + swipes: Int +) { + AccountRow(accountName, "$swipes remaining") +} + +@Composable +private fun AccountRow( + accountName: String, + text: String ) { Row( modifier = Modifier.height(50.dp), @@ -423,28 +522,22 @@ fun AccountBalanceRow( text = accountName, style = EateryBlueTypography.button, ) - if (balance != null) { - Text( - modifier = Modifier.weight(1f), - textAlign = TextAlign.Right, - text = if (!isMealSwipes) { - "$" + "%.2f".format(balance.toFloat()) - } else { - "%.0f".format(balance.toFloat()) + " remaining" - }, - style = EateryBlueTypography.button, - ) - } + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Right, + text = text, + style = EateryBlueTypography.button, + ) } } @Composable -fun AccountTypesAvailable( - selectedPaymentMethod: List, - accountFilter: AccountType, +fun AccountTypesSelector( + selectedPaymentMethod: List, + accountFilter: TransactionAccountType, hide: () -> Unit, - onSubmit: (AccountType) -> Unit + onSubmit: (TransactionAccountType) -> Unit ) { var selected by remember { mutableStateOf(accountFilter) } Column( @@ -464,52 +557,46 @@ fun AccountTypesAvailable( ) IconButton( - onClick = { - hide() - }, + onClick = hide, modifier = Modifier .size(40.dp) .background(color = GrayZero, shape = CircleShape) ) { - Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.Black) + Icon(Icons.Default.Close, contentDescription = "Close", tint = Black) } } Column { selectedPaymentMethod.forEachIndexed { index, account -> - val select = when (selected) { - account -> true - else -> false - } + val accountIsSelected = selected == account Row( modifier = Modifier .height(63.dp) .fillMaxWidth() .selectable( - selected = (select), + selected = accountIsSelected, onClick = { selected = account } ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Text( - text = when (account.name) { - "MEALSWIPES" -> "Meal Swipes" - "BRBS" -> "Big Red Bucks" - "LAUNDRY" -> "Laundry" - "CITYBUCKS" -> "City Bucks" - else -> "Account Type" + text = when (account) { + TransactionAccountType.MEAL_SWIPES -> "Meal Swipes" + TransactionAccountType.BRBS -> "Big Red Bucks" + TransactionAccountType.LAUNDRY -> "Laundry" + TransactionAccountType.CITY_BUCKS -> "City Bucks" }, style = EateryBlueTypography.h5, modifier = Modifier.padding(start = 16.dp) ) IconToggleButton( - checked = (select), + checked = accountIsSelected, onCheckedChange = { selected = account }, modifier = Modifier.padding(end = 16.dp) ) { Icon( modifier = Modifier.size(32.dp), imageVector = ImageVector.vectorResource( - id = if (select) R.drawable.ic_selected else R.drawable.ic_unselected + id = if (accountIsSelected) R.drawable.ic_selected else R.drawable.ic_unselected ), contentDescription = null, tint = Color.Unspecified diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/AccountScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/AccountScreen.kt deleted file mode 100644 index ef58078f..00000000 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/AccountScreen.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.cornellappdev.android.eatery.ui.screens - -import androidx.compose.runtime.Composable -import com.cornellappdev.android.eatery.data.models.User - -@Composable -fun AccountScreen() { - -} - -object CurrentUser { - var user: User? = null -} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 2f2cec7f..944e9cbb 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -4,8 +4,9 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.tooling.preview.Preview -import com.cornellappdev.android.eatery.data.models.AccountType +import com.cornellappdev.android.eatery.data.models.AccountBalances import com.cornellappdev.android.eatery.data.models.Transaction +import com.cornellappdev.android.eatery.data.models.TransactionAccountType import com.cornellappdev.android.eatery.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel @@ -23,16 +24,17 @@ fun ProfileScreen( val state = loginViewModel.state.collectAsState().value ProfileScreenContent( isLoginState = state is LoginViewModel.State.Login, - accountTypeBalance = state.getBalanceMap(), + accountTypeBalance = state.getBalances(), loading = state is LoginViewModel.State.Login && state.loading, onLoginPressed = loginViewModel::onLoginPressed, onSuccess = loginViewModel::onLoginWebViewSuccess, webViewEnabled = webViewEnabled, onBackClick = onBackClick, onModalHidden = loginViewModel::onLoginExited, - accountFilter = if (state is LoginViewModel.State.Account) state.accountFilter else AccountType.BRBS, onSettingsClicked = onSettingsClicked, - getTransactionsOfType = loginViewModel::getTransactionsOfType, + accountFilter = if (state is LoginViewModel.State.Account) state.accountFilter else TransactionAccountType.BRBS, + + getTransactionsOfType = loginViewModel::getFilteredTransactions, updateAccountFilter = loginViewModel::updateAccountFilter ) } @@ -40,17 +42,17 @@ fun ProfileScreen( @Composable private fun ProfileScreenContent( isLoginState: Boolean, - accountTypeBalance: Map, + accountTypeBalance: AccountBalances, loading: Boolean, onLoginPressed: () -> Unit, onSuccess: (String) -> Unit, webViewEnabled: Boolean, onBackClick: () -> Unit, onModalHidden: () -> Unit, - accountFilter: AccountType, + accountFilter: TransactionAccountType, onSettingsClicked: () -> Unit, - getTransactionsOfType: (AccountType, String) -> List, - updateAccountFilter: (AccountType) -> Unit + getTransactionsOfType: (TransactionAccountType, String) -> List, + updateAccountFilter: (TransactionAccountType) -> Unit ) { if (isLoginState) { LoginPage( @@ -83,11 +85,11 @@ private fun ProfileLoginScreenPreview() = EateryPreview { ) ProfileScreenContent( isLoginState = false, - accountTypeBalance = mapOf( - AccountType.BRBS to 1234.56, - AccountType.CITYBUCKS to 78.90, - AccountType.LAUNDRY to 12.34, - AccountType.MEALSWIPES to 4.20 + accountTypeBalance = AccountBalances( + brbBalance = 1234.56, + cityBucksBalance = 78.90, + laundryBalance = 12.34, + mealSwipes = 30 ), loading = false, onLoginPressed = {}, @@ -95,7 +97,7 @@ private fun ProfileLoginScreenPreview() = EateryPreview { webViewEnabled = false, onBackClick = {}, onModalHidden = {}, - accountFilter = AccountType.BRBS, + accountFilter = TransactionAccountType.BRBS, onSettingsClicked = {}, getTransactionsOfType = { _, _ -> emptyList() }, updateAccountFilter = {}, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/theme/Color.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/theme/Color.kt index 2f5e7dc2..a6d1ab8f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/theme/Color.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/theme/Color.kt @@ -16,6 +16,7 @@ val Red = Color(0xFFF2655D) val Green = Color(0xFF63C774) val Yellow = Color(0xFFFEC50E) val Orange = Color(0xFFFF990E) +val Black = Color(0xFF050505) /** * Interpolates a color between [color1] and [color2] by choosing a color a [fraction] in between. diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt index 162833f8..8faa8245 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt @@ -26,8 +26,8 @@ import javax.inject.Inject @HiltViewModel class CompareMenusBotViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, - private val eateryRepository: EateryRepository, + userPreferencesRepository: UserPreferencesRepository, + eateryRepository: EateryRepository, private val userRepository: UserRepository, ) : ViewModel() { @@ -69,7 +69,7 @@ class CompareMenusBotViewModel @Inject constructor( userPreferencesRepository.favoritesFlow, filtersFlow, selectedEateriesFlow - ) { eateriesApiResponse, favorites, filters, selected -> + ) { eateriesApiResponse, _, filters, selected -> when (eateriesApiResponse) { is EateryApiResponse.Success -> { _compareMenusUiState.update { currentState -> diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index adfc2989..be769abf 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -3,12 +3,13 @@ package com.cornellappdev.android.eatery.ui.viewmodels import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.cornellappdev.android.eatery.data.models.AccountType +import com.cornellappdev.android.eatery.data.models.AccountBalances import com.cornellappdev.android.eatery.data.models.Transaction +import com.cornellappdev.android.eatery.data.models.TransactionAccountType import com.cornellappdev.android.eatery.data.models.User +import com.cornellappdev.android.eatery.data.models.toTransactionAccountType import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository -import com.cornellappdev.android.eatery.ui.screens.CurrentUser import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -39,27 +40,28 @@ class LoginViewModel @Inject constructor( data class Account( val user: User, // Contains all user data. var query: String, // Search bar query. - var accountFilter: AccountType // Search bar filter. + var accountFilter: TransactionAccountType ) : State() - fun getBalanceMap(): Map { - if (this !is Account) return mapOf() - val balanceMap = mutableMapOf() - this.user.accounts?.forEach { account -> - if (account.type != null) { - balanceMap[account.type] = account.balance - } - } - return balanceMap + fun getBalances(): AccountBalances { + if (this !is Account) return AccountBalances() + return AccountBalances( + brbBalance = this.user.brbBalance, + cityBucksBalance = this.user.cityBucksBalance, + laundryBalance = this.user.laundryBalance, + mealSwipes = this.user.mealSwipes + ) } } private var _state = MutableStateFlow( - if (CurrentUser.user == null) { - State.Login() - } else { - State.Account(CurrentUser.user!!, "", AccountType.BRBS) - } + userRepository.loadedUser?.let { + State.Account( + user = it, + query = "", + accountFilter = TransactionAccountType.BRBS + ) + } ?: State.Login() ) // Convert the state to a flow that can be updated by screens that use the LoginViewModel @@ -73,7 +75,7 @@ class LoginViewModel @Inject constructor( _state.value = State.Login() } - fun updateAccountFilter(newAccountType: AccountType) { + fun updateAccountFilter(newAccountType: TransactionAccountType) { val currState = _state.value if (currState !is State.Account) return @@ -88,15 +90,25 @@ class LoginViewModel @Inject constructor( _state.value = newState } - fun getTransactionsOfType(accountType: AccountType, query: String): List { + fun getFilteredTransactions( + accountType: TransactionAccountType, + query: String + ): List { val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") - if (_state.value !is State.Account || CurrentUser.user == null) return listOf() - return CurrentUser.user!!.transactions?.filter { transaction -> - transaction.accountType == accountType - && LocalDateTime.parse(transaction.date, inputFormatter) >= LocalDateTime.now() - .minusDays(30) - && transaction.location!!.lowercase().contains(query.lowercase()) - } ?: listOf() + userRepository.loadedUser?.let { + if (_state.value !is State.Account) return emptyList() + return it.transactions?.filter { transaction -> + val matchesAccountType = + transaction.accountType.toTransactionAccountType() == accountType + val pastThirtyDays = LocalDateTime.parse( + transaction.date, + inputFormatter + ) >= LocalDateTime.now().minusDays(30) + val matchesQuery = transaction.location.lowercase().contains(query.lowercase()) + matchesAccountType && pastThirtyDays && matchesQuery + } ?: emptyList() + } + return emptyList() } fun onLoginPressed() = updateLoginLoadingState(true) @@ -116,7 +128,7 @@ class LoginViewModel @Inject constructor( val newState = State.Login() _state.value = newState viewModelScope.launch { - CurrentUser.user = null + userRepository.loadedUser = null userPreferencesRepository.setIsLoggedIn(false) userPreferencesRepository.saveLoginInfo("", "") } @@ -147,7 +159,7 @@ class LoginViewModel @Inject constructor( val deviceId = userPreferencesRepository.getDeviceId()!! Log.d("debug", "sessionId: $sessionId, deviceId: $deviceId, fcmToken: $fcmToken") val user = userRepository.getUser(sessionId, deviceId, fcmToken) - CurrentUser.user = user + userRepository.loadedUser = user if (currState is State.Login) { userPreferencesRepository.saveLoginInfo(sessionId, currState.password) userPreferencesRepository.setIsLoggedIn(true) @@ -155,7 +167,7 @@ class LoginViewModel @Inject constructor( val newState = State.Account( user = user, query = "", - accountFilter = AccountType.BRBS + accountFilter = TransactionAccountType.BRBS ) _state.value = newState } catch (e: Exception) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/util/Constants.kt b/app/src/main/java/com/cornellappdev/android/eatery/util/Constants.kt index fd218af0..f99b6c95 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/util/Constants.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/util/Constants.kt @@ -18,7 +18,7 @@ object Constants { "unlimited" to AccountType.UNLIMITED, "basic" to AccountType.BEAR_BASIC, "choice" to AccountType.BEAR_CHOICE, - "house meal plan" to AccountType.HOUSE_MEALPLAN, + "house meal plan" to AccountType.HOUSE_MEAL_PLAN, "house affiliate" to AccountType.HOUSE_AFFILIATE, "flex" to AccountType.FLEX, "just bucks" to AccountType.JUST_BUCKS From 3e6528a3fb0b4fb455c32b84a7db9845b3bf7af8 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 19 Nov 2025 16:29:00 -0500 Subject: [PATCH 052/126] Fix exposed loadedUser variable, use firstOrNull, plus other minor fixes. --- .../android/eatery/data/models/Eatery.kt | 46 ------------------- .../data/repositories/EateryRepository.kt | 2 +- .../repositories/UserPreferencesRepository.kt | 3 +- .../data/repositories/UserRepository.kt | 13 +++++- .../eatery/ui/components/login/AccountPage.kt | 43 +++++++++++++++-- .../eatery/ui/viewmodels/LoginViewModel.kt | 20 ++++---- 6 files changed, 63 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt index 0b25652f..dbdff9ee 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt @@ -19,7 +19,6 @@ import java.time.Duration import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit import java.util.Date @JsonClass(generateAdapter = true) @@ -57,29 +56,6 @@ data class Eatery( return ((results[0] / AVERAGE_WALK_SPEED) / 60).toInt() } - fun getWaitTimes(): String? { - if (waitTimes.isNullOrEmpty()) - return null - - val waitTimeDay = waitTimes.find { waitTimeDay -> - // checks if today is the right day - waitTimeDay.canonicalDate - ?.toInstant() - ?.truncatedTo(ChronoUnit.DAYS) - ?.equals(Date().toInstant().truncatedTo(ChronoUnit.DAYS)) ?: true - }?.data - - val waitTimes: WaitTimeData? = waitTimeDay?.find { waitTimeData -> - waitTimeData.timestamp?.isBefore(LocalDateTime.now()) == true - } - - return if (waitTimes != null) { - "${waitTimes.waitTimeLow?.div(60)}-${waitTimes.waitTimeHigh?.div(60)}" - } else { - null - } - } - private fun getTodaysEvents(): List { val currentTime = LocalDateTime.now() @@ -112,18 +88,6 @@ data class Eatery( return todayEvents } - /** - * Returns the currently active event, or null if no event is active. - * - * Example: At 1 PM, Morrison will return the lunch event. - */ - fun getCurrentEvent(): Event? { - return getTodaysEvents().find { - it.startTime?.isBefore(LocalDateTime.now()) ?: true - && it.endTime?.isAfter(LocalDateTime.now()) ?: true - } - } - /** * Returns the event that should be displayed at the Ithaca local time * If there is currently a meal going on, that is displayed @@ -239,16 +203,6 @@ data class Eatery( return getOpenUntil() == null } - fun isClosingInTen(): Boolean { - val currentTime = LocalDateTime.now() - val currentEvents = getCurrentEvents() - if (currentEvents.isEmpty()) - return false - - val endTime = currentEvents.first().endTime ?: return false - return currentTime.plusMinutes(10).isAfter(endTime) - } - /** * Returns true if the eatery has a current event and that event is ending within [minutes]. */ diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index dbfd6604..abcb661d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -71,7 +71,7 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { try { val eateries = getAllEateries() _eateryFlow.value = EateryApiResponse.Success(eateries) - eateryApiCache.update { _ -> + eateryApiCache.update { eateries.filter { it.id != null } .associate { it.id!! to EateryApiResponse.Success(it) } .withDefault { EateryApiResponse.Error } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 52ff8bce..b3d1d9fa 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -137,7 +138,7 @@ class UserPreferencesRepository @Inject constructor( } suspend fun getDeviceId(): String? { - val id: String? = userPreferencesFlow.first().deviceId + val id: String? = userPreferencesFlow.firstOrNull()?.deviceId return if (id.isNullOrEmpty()) null else id } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index e0cf2a96..930f0983 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -4,15 +4,21 @@ import com.cornellappdev.android.eatery.data.NetworkApi import com.cornellappdev.android.eatery.data.models.LoginRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody import com.cornellappdev.android.eatery.data.models.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton @Singleton class UserRepository @Inject constructor(private val networkApi: NetworkApi) { + private val _loadedUser: MutableStateFlow = MutableStateFlow(null) + /** * The currently loaded user. Null if no user is logged in. */ - var loadedUser: User? = null + val loadedUser: StateFlow = _loadedUser.asStateFlow() + suspend fun sendReport(issue: String, report: String, eateryID: Int?): Any = networkApi.sendReport( @@ -47,6 +53,11 @@ class UserRepository @Inject constructor(private val networkApi: NetworkApi) { val userWithData = networkApi.getUserData( id = authorizedUser.id ).copy(transactions = transactions) + _loadedUser.value = userWithData return userWithData } + + fun logout() { + _loadedUser.value = null + } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index a931dd9f..2b443f32 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.TextStyle 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 com.cornellappdev.android.eatery.R @@ -66,7 +67,7 @@ import com.cornellappdev.android.eatery.ui.theme.GrayFive import com.cornellappdev.android.eatery.ui.theme.GrayZero import com.cornellappdev.android.eatery.ui.theme.Green import com.cornellappdev.android.eatery.ui.theme.Red -import kotlinx.coroutines.CoroutineScope +import com.cornellappdev.android.eatery.util.EateryPreview import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -129,7 +130,6 @@ fun AccountPage( onSettingsClicked, accountTypeBalance, accountFilter, - coroutineScope, showBottomSheet = modalBottomSheetState::show, filterText, setFilterText = { filterText = it }, @@ -150,7 +150,6 @@ private fun AccountPageContent( onSettingsClicked: () -> Unit, accountTypeBalance: AccountBalances, accountFilter: TransactionAccountType, - coroutineScope: CoroutineScope, showBottomSheet: suspend () -> Unit, filterText: String, setFilterText: (String) -> Unit, @@ -233,7 +232,6 @@ private fun AccountPageContent( TransactionsHeader( accountFilter, setSheetContent, - coroutineScope, showBottomSheet, filterText, setFilterText @@ -254,15 +252,50 @@ private fun AccountPageContent( } } +@Preview +@Composable +private fun AccountPagePreview() = EateryPreview { + AccountPageContent( + onSettingsClicked = {}, + accountTypeBalance = AccountBalances( + brbBalance = 25.50, + cityBucksBalance = 10.75, + laundryBalance = 5.00, + mealSwipes = 42 + ), + accountFilter = TransactionAccountType.BRBS, + showBottomSheet = {}, + filterText = "", + setFilterText = {}, + getTransactionsOfType = { _, _ -> + listOf( + Transaction( + date = "2023-10-01T12:30:00.000Z", + location = "Cafe Jennie", + amount = 5.25, + transactionType = TransactionType.SPEND + ), + Transaction( + date = "2023-10-02T14:00:00.000Z", + location = "Morrison Dining", + amount = 15.00, + transactionType = TransactionType.DEPOSIT + ) + ) + }, + setSheetContent = {} + ) +} + @Composable private fun TransactionsHeader( accountFilter: TransactionAccountType, setSheetContent: (BottomSheetContent) -> Unit, - coroutineScope: CoroutineScope, showBottomSheet: suspend () -> Unit, filterText: String, setFilterText: ((String) -> Unit) ) { + val coroutineScope = rememberCoroutineScope() Column( modifier = Modifier .background(color = Color.White) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index be769abf..3fc72855 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -55,13 +55,14 @@ class LoginViewModel @Inject constructor( } private var _state = MutableStateFlow( - userRepository.loadedUser?.let { - State.Account( - user = it, - query = "", - accountFilter = TransactionAccountType.BRBS - ) - } ?: State.Login() + userRepository.loadedUser.value + ?.let { + State.Account( + user = it, + query = "", + accountFilter = TransactionAccountType.BRBS + ) + } ?: State.Login() ) // Convert the state to a flow that can be updated by screens that use the LoginViewModel @@ -95,7 +96,7 @@ class LoginViewModel @Inject constructor( query: String ): List { val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") - userRepository.loadedUser?.let { + userRepository.loadedUser.value?.let { if (_state.value !is State.Account) return emptyList() return it.transactions?.filter { transaction -> val matchesAccountType = @@ -128,7 +129,7 @@ class LoginViewModel @Inject constructor( val newState = State.Login() _state.value = newState viewModelScope.launch { - userRepository.loadedUser = null + userRepository.logout() userPreferencesRepository.setIsLoggedIn(false) userPreferencesRepository.saveLoginInfo("", "") } @@ -159,7 +160,6 @@ class LoginViewModel @Inject constructor( val deviceId = userPreferencesRepository.getDeviceId()!! Log.d("debug", "sessionId: $sessionId, deviceId: $deviceId, fcmToken: $fcmToken") val user = userRepository.getUser(sessionId, deviceId, fcmToken) - userRepository.loadedUser = user if (currState is State.Login) { userPreferencesRepository.saveLoginInfo(sessionId, currState.password) userPreferencesRepository.setIsLoggedIn(true) From 8aebb232cbed1ad0150cb36dae12c5d6e722f34b Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 18 Feb 2026 15:22:14 -0500 Subject: [PATCH 053/126] Initial migration to new backend --- app/build.gradle | 1 + .../android/eatery/MainActivity.kt | 22 +- .../android/eatery/data/MoshiAdapters.kt | 11 +- .../android/eatery/data/NetworkingApi.kt | 117 ++++++--- .../android/eatery/data/models/Eatery.kt | 169 +++++++----- .../android/eatery/data/models/User.kt | 81 +++++- .../data/repositories/EateryRepository.kt | 40 +-- .../repositories/UserPreferencesRepository.kt | 114 +++------ .../data/repositories/UserRepository.kt | 240 +++++++++++++++--- .../android/eatery/di/NetworkingModule.kt | 14 +- .../ui/components/details/AlertsSection.kt | 2 +- .../details/EateryDetailsStickyHeader.kt | 14 +- .../details/EateryMenusBottomSheet.kt | 129 +++++----- .../ui/components/details/PaymentWidgets.kt | 6 +- .../ui/components/general/EateryCard.kt | 12 +- .../eatery/ui/components/general/Filter.kt | 22 +- .../eatery/ui/components/general/MenuItems.kt | 2 +- .../ui/components/upcoming/MealBottomSheet.kt | 9 +- .../ui/navigation/MainTabbedNavigation.kt | 15 +- .../eatery/ui/screens/CompareMenusScreen.kt | 5 +- .../eatery/ui/screens/EateryDetailScreen.kt | 19 +- .../eatery/ui/screens/FavoritesScreen.kt | 2 +- .../android/eatery/ui/screens/HomeScreen.kt | 10 +- .../eatery/ui/screens/ProfileScreen.kt | 3 +- .../eatery/ui/screens/SettingsScreen.kt | 3 +- .../ui/viewmodels/CompareMenusBotViewModel.kt | 7 +- .../ui/viewmodels/EateryDetailViewModel.kt | 40 +-- .../ui/viewmodels/FavoritesViewModel.kt | 34 ++- .../eatery/ui/viewmodels/HomeViewModel.kt | 36 ++- .../eatery/ui/viewmodels/LoginViewModel.kt | 80 +++--- .../eatery/ui/viewmodels/NearestViewModel.kt | 29 ++- .../ui/viewmodels/OnboardingViewModel.kt | 2 +- .../eatery/ui/viewmodels/SearchViewModel.kt | 37 ++- .../eatery/ui/viewmodels/UpcomingViewModel.kt | 26 +- app/src/main/proto/user_prefs.proto | 22 +- 35 files changed, 834 insertions(+), 541 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 9a0f9b74..7e357385 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,6 +105,7 @@ dependencies { // Networking implementation("com.squareup.moshi:moshi:1.14.0") implementation("com.squareup.moshi:moshi-kotlin:1.14.0") + implementation 'com.squareup.moshi:moshi-adapters:1.14.0' implementation 'com.squareup.okhttp3:okhttp' implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.10' implementation 'com.squareup.retrofit2:retrofit:2.9.0' diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index ee46a886..7262f2e6 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -7,7 +7,7 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.cornellappdev.android.eatery.data.repositories.EateryRepository -import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository +import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.navigation.NavigationSetup import com.cornellappdev.android.eatery.util.LockScreenOrientation import dagger.hilt.android.AndroidEntryPoint @@ -17,17 +17,15 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { @Inject - lateinit var userPreferences: UserPreferencesRepository + lateinit var eateryRepository: EateryRepository @Inject - lateinit var eateryRepository: EateryRepository + lateinit var userRepository: UserRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val hasOnboarded = runBlocking { - return@runBlocking userPreferences.getHasOnboarded() - } + val hasOnboarded = runBlocking { userRepository.hasOnboarded() } WindowCompat.setDecorFitsSystemWindows(window, false) @@ -46,5 +44,17 @@ class MainActivity : ComponentActivity() { } } lifecycle.addObserver(dataRefresher) + runBlocking { + configureTokens() + // todo - uncomment when backend finishes favorites +// userRepository.updateFavorites() + } + } + + private suspend fun configureTokens() { + if (!userRepository.hasLaunchedBefore()) { + userRepository.registerDevice() + } + userRepository.getTokens() } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt index 398cdea5..0bda78e6 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt @@ -87,14 +87,9 @@ class DateTimeAdapter { } @FromJson - fun fromJson(dateTime: Long): LocalDateTime { - try { - val instant = Instant.ofEpochSecond(dateTime) - return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) - } catch (e: ParseException) { - e.printStackTrace() - } - return LocalDateTime.MIN + fun fromJson(dateTime: String): LocalDateTime { + val x = LocalDateTime.ofInstant(Instant.parse(dateTime), ZoneId.systemDefault()) + return x } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index 3db22dbe..382ad960 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -1,54 +1,113 @@ package com.cornellappdev.android.eatery.data -import com.cornellappdev.android.eatery.data.models.Accounts -import com.cornellappdev.android.eatery.data.models.AuthorizedUser +import com.cornellappdev.android.eatery.data.models.AuthTokens +import com.cornellappdev.android.eatery.data.models.DeviceId import com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.FavoriteEatery +import com.cornellappdev.android.eatery.data.models.FavoriteItem +import com.cornellappdev.android.eatery.data.models.FavoritesResponse +import com.cornellappdev.android.eatery.data.models.FcmToken +import com.cornellappdev.android.eatery.data.models.Financials import com.cornellappdev.android.eatery.data.models.GetApiResponse +import com.cornellappdev.android.eatery.data.models.LoginPIN import com.cornellappdev.android.eatery.data.models.LoginRequest +import com.cornellappdev.android.eatery.data.models.RefreshRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody -import com.cornellappdev.android.eatery.data.models.Transactions -import com.cornellappdev.android.eatery.data.models.User +import com.cornellappdev.android.eatery.data.models.SessionID import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path interface NetworkApi { - @GET("/eatery/") + @GET("/eateries/") suspend fun fetchEateries(): List - @GET("/eatery/{eatery_id}") + @GET("/eateries/{eatery_id}") suspend fun fetchEatery(@Path(value = "eatery_id") eateryId: String): Eatery - @GET("/eatery/simple") - suspend fun fetchHomeEateries(): List - @POST("/report/") suspend fun sendReport( @Body report: ReportSendBody ): GetApiResponse - @POST("/user/authorize/") + /** + * Called on app launch to get session tokens based on UUID + */ + @POST("/auth/verify-token") + suspend fun verifyToken( + @Body deviceId: DeviceId + ): AuthTokens + + /** + * Get a new pair of tokens + */ + @POST("/auth/refresh-token") + suspend fun refreshToken( + @Body refreshRequest: RefreshRequest + ): AuthTokens + + /* All [accessToken]s should start with "Bearer". + * E.g., Authorization: Bearer a97syd9a77asydan9s + * */ + + @POST("/user/fcm-token") + suspend fun enableNotifications( + @Header("Authorization") accessToken: String, + @Body token: FcmToken + ) + + @DELETE("/user/fcm-token") + suspend fun disableNotifications( + @Header("Authorization") accessToken: String, + @Body token: FcmToken + ) + + @POST("/user/favorites/items") + suspend fun addFavoriteItem( + @Header("Authorization") accessToken: String, + @Body item: FavoriteItem + ) + + @DELETE("/user/favorites/items") + suspend fun deleteFavoriteItem( + @Header("Authorization") accessToken: String, + @Body item: FavoriteItem + ) + + @POST("/user/favorites/eateries") + suspend fun addFavoriteEatery( + @Header("Authorization") accessToken: String, + @Body eatery: FavoriteEatery + ) + + @DELETE("/user/favorites/eateries") + suspend fun deleteFavoriteEatery( + @Header("Authorization") accessToken: String, + @Body eatery: FavoriteEatery + ) + + @POST("/auth/get/authorize") suspend fun authorizeUser( - @Header("Authorization") sessionId: String, + @Header("Authorization") accessToken: String, @Body loginRequest: LoginRequest - ): AuthorizedUser - - @POST("/user/accounts/") - suspend fun getUserAccounts( - @Header("Authorization") sessionId: String, - @Body user: AuthorizedUser - ): Accounts - - @POST("/user/transactions/") - suspend fun getUserTransactions( - @Header("Authorization") sessionId: String, - @Body user: AuthorizedUser - ): Transactions - - @GET("/user/{id}/") - suspend fun getUserData( - @Path("id") id: Long - ): User + ) + + @POST("/auth/get/refresh") + suspend fun refreshAuthorizedUser( + @Header("Authorization") accessToken: String, + @Body loginPIN: LoginPIN + ): SessionID + + @GET("/financials") + suspend fun getFinancials( + @Header("Authorization") accessToken: String + ): Financials + + @GET("/user/favorites/matches") + suspend fun getFavoriteMatches( + @Header("Authorization") accessToken: String, + ): FavoritesResponse } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt index dbdff9ee..6d798727 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.graphics.Color import com.cornellappdev.android.eatery.ui.components.general.MealFilter import com.cornellappdev.android.eatery.util.Constants.AVERAGE_WALK_SPEED import com.cornellappdev.android.eatery.util.LocationHandler -import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -23,23 +22,30 @@ import java.util.Date @JsonClass(generateAdapter = true) data class Eatery( - @Json(name = "id") val id: Int? = null, - @Json(name = "name") val name: String? = null, - @Json(name = "menu_summary") val menuSummary: String? = null, - @Json(name = "image_url") val imageUrl: String? = null, - @Json(name = "location") val location: String? = null, - @Json(name = "campus_area") val campusArea: String? = null, - @Json(name = "online_order_url") val onlineOrderUrl: String? = null, - @Json(name = "latitude") val latitude: Float? = null, - @Json(name = "longitude") val longitude: Float? = null, - @Json(name = "payment_accepts_meal_swipes") val paymentAcceptsMealSwipes: Boolean? = null, - @Json(name = "payment_accepts_brbs") val paymentAcceptsBrbs: Boolean? = null, - @Json(name = "payment_accepts_cash") val paymentAcceptsCash: Boolean? = null, - @Json(name = "events") val events: List? = null, - @Json(name = "wait_times") val waitTimes: List? = null, - @Json(name = "alerts") val alerts: List? = null, + val id: Int? = null, + val cornellId: Int? = null, + val announcements: List? = null, + val name: String? = null, + val shortName: String? = null, + val about: String? = null, + val shortAbout: String? = null, + val cornellDining: Boolean? = null, + val menuSummary: String? = null, + val imageUrl: String? = null, + val campusArea: String? = null, + val onlineOrderUrl: String? = null, + val contactPhone: String? = null, + val contactEmail: String? = null, + val latitude: Float? = null, + val longitude: Float? = null, + val location: String? = null, + val paymentMethods: List? = null, + val eateryTypes: List? = null, + val createdAt: LocalDateTime? = null, + val events: List? = null, + val waitTimes: List? = null, + val alerts: List? = null, ) { - // todo - investigate unused methods fun getWalkTimes(): Int? { val currentLocation = LocationHandler.currentLocation.value val results = floatArrayOf(0f) @@ -63,17 +69,17 @@ data class Eatery( return listOf() val todayEvents = events.filter { event -> - currentTime.dayOfYear == event.startTime?.dayOfYear - }.sortedBy { it.startTime } + currentTime.dayOfYear == event.startTimestamp?.dayOfYear + }.sortedBy { it.startTimestamp } // is sorting them here too slow? todayEvents.forEach { event -> var i = 0 val chefs: MutableList = mutableListOf() event.menu?.forEach { menuCategory -> - if (menuCategory.category != null && menuCategory.category == "Chef's Table") { + if (menuCategory.name != null && menuCategory.name == "Chef's Table") { val chef = event.menu[i] chefs.add(chef) - } else if (menuCategory.category != null && menuCategory.category == "Chef's Table - Sides") { + } else if (menuCategory.name != null && menuCategory.name == "Chef's Table - Sides") { val chef = event.menu[i] chefs.add(0, chef) } @@ -101,9 +107,10 @@ data class Eatery( val now = LocalDateTime.now() val todayEvents = getTodaysEvents() val currentEvent = todayEvents.find { event -> - (event.startTime?.isBefore(now) ?: true) && (event.endTime?.isAfter(now) ?: true) + (event.startTimestamp?.isBefore(now) ?: true) && (event.endTimestamp?.isAfter(now) + ?: true) } - return currentEvent ?: todayEvents.find { it.startTime?.isAfter(now) ?: true } + return currentEvent ?: todayEvents.find { it.startTimestamp?.isAfter(now) ?: true } ?: todayEvents.lastOrNull() } @@ -118,8 +125,8 @@ data class Eatery( val targetDate = LocalDate.now().plusDays(dayIndex.toLong()) val ans = events?.find { - it.description.equals(mealDescription, ignoreCase = true) && - (it.startTime?.toLocalDate()?.isEqual(targetDate) == true) + it.type.equals(mealDescription, ignoreCase = true) && + (it.startTimestamp?.toLocalDate()?.isEqual(targetDate) == true) } return ans } @@ -138,11 +145,11 @@ data class Eatery( val uniqueMeals = LinkedHashMap() - events?.filter { it.startTime?.dayOfWeek == currSelectedDay } + events?.filter { it.startTimestamp?.dayOfWeek == currSelectedDay } ?.forEach { event -> - val description = event.description - val startTime = event.startTime - val endTime = event.endTime + val description = event.type + val startTime = event.startTimestamp + val endTime = event.endTimestamp if (description != null && startTime != null && endTime != null && !uniqueMeals.containsKey( description ) @@ -173,7 +180,7 @@ data class Eatery( var currentDay = LocalDate.now() currentDay = currentDay.plusDays(day.toLong()) return events?.filter { event -> - currentDay.dayOfYear == event.startTime?.dayOfYear && meal.text.contains(event.description) + currentDay.dayOfYear == event.startTimestamp?.dayOfYear && meal.text.contains(event.type) } } @@ -183,9 +190,9 @@ data class Eatery( return listOf() return events.filter { event -> - (currentTime.isAfter(event.startTime) || currentTime.isEqual(event.startTime)) && (currentTime.isBefore( - event.endTime - ) || currentTime.isEqual(event.endTime)) + (currentTime.isAfter(event.startTimestamp) || currentTime.isEqual(event.startTimestamp)) && (currentTime.isBefore( + event.endTimestamp + ) || currentTime.isEqual(event.endTimestamp)) } } @@ -195,7 +202,7 @@ data class Eatery( if (currentEvents.isEmpty()) return null - val endTime = currentEvents.first().endTime ?: return null + val endTime = currentEvents.first().endTimestamp ?: return null return "${endTime.format(DateTimeFormatter.ofPattern("K:mm a"))}" } @@ -214,7 +221,7 @@ data class Eatery( if (currentEvents.isEmpty()) return false - val endTime = currentEvents.first().endTime + val endTime = currentEvents.first().endTimestamp val timeBuffer: Long = Duration.between(currentTime, endTime).toMinutes() return timeBuffer < minutes @@ -225,7 +232,7 @@ data class Eatery( val currentEvents = getCurrentEvents() if (currentEvents.isEmpty()) return null - val endTime = currentEvents.first().endTime ?: return null + val endTime = currentEvents.first().endTimestamp ?: return null var timeBuffer: Long = Duration.between(currentTime, endTime).toMinutes() return flow { @@ -244,6 +251,22 @@ data class Eatery( ) } + fun acceptsMealSwipes(): Boolean = paymentMethods?.contains(PaymentMethod.MEAL_SWIPE) ?: false + + fun acceptsCard(): Boolean = paymentMethods?.contains(PaymentMethod.CARD) ?: false + + fun acceptsCash(): Boolean = paymentMethods?.contains(PaymentMethod.CASH) ?: false + + fun acceptsBRB(): Boolean = paymentMethods?.contains(PaymentMethod.BRB) ?: false + +// fun acceptsMealSwipes(): Boolean = paymentMethods?.contains("MEAL_SWIPE") ?: false +// +// fun acceptsCard(): Boolean = paymentMethods?.contains("CARD") ?: false +// +// fun acceptsCash(): Boolean = paymentMethods?.contains("CASH") ?: false +// +// fun acceptsBRB(): Boolean = paymentMethods?.contains("BRB") ?: false + /** * Private helper function that returns a map of the day of week that a eatery is open * to the opening time(s) or closed status (these are strings) @@ -254,9 +277,9 @@ data class Eatery( val dailyHours = mutableMapOf>() events?.forEach { event -> - val dayOfWeek = event.startTime?.dayOfWeek - val openTime = event.startTime?.format(DateTimeFormatter.ofPattern("h:mm a")) - val closeTime = event.endTime?.format(DateTimeFormatter.ofPattern("h:mm a")) + val dayOfWeek = event.startTimestamp?.dayOfWeek + val openTime = event.startTimestamp?.format(DateTimeFormatter.ofPattern("h:mm a")) + val closeTime = event.endTimestamp?.format(DateTimeFormatter.ofPattern("h:mm a")) val timeString = "$openTime - $closeTime" if (dayOfWeek != null && dailyHours[dayOfWeek]?.none { it.contains(timeString) } != false) { @@ -389,53 +412,73 @@ data class Eatery( @JsonClass(generateAdapter = true) data class Alert( - @Json(name = "id") val id: Int? = null, - @Json(name = "description") val description: String? = null, - @Json(name = "start_timestamp") val startTime: LocalDateTime? = null, - @Json(name = "end_timestamp") val endTime: LocalDateTime? = null + val id: Int? = null, + val description: String? = null, + val startTimestamp: LocalDateTime? = null, + val endTimestamp: LocalDateTime? = null ) @JsonClass(generateAdapter = true) data class WaitTimeDay( - @Json(name = "canonical_date") val canonicalDate: Date? = null, - @Json(name = "data") val data: List? = null + val canonicalDate: Date? = null, + val data: List? = null ) @JsonClass(generateAdapter = true) data class WaitTimeData( - @Json(name = "timestamp") val timestamp: LocalDateTime? = null, - @Json(name = "wait_time_low") val waitTimeLow: Int? = null, - @Json(name = "wait_time_expected") val waitTimeExpected: Int? = null, - @Json(name = "wait_time_high") val waitTimeHigh: Int? = null + val timestamp: LocalDateTime? = null, + val waitTimeLow: Int? = null, + val waitTimeExpected: Int? = null, + val waitTimeHigh: Int? = null ) +@JsonClass(generateAdapter = true) +enum class PaymentMethod { + CASH, + MEAL_SWIPE, + CARD, + BRB, + FREE, + UNKNOWN +} + @JsonClass(generateAdapter = true) data class Event( - @Json(name = "id") val id: Int? = null, + val id: Int? = null, /** - * Descriptions tend to be "Lunch", "Dinner", etc.. + * "Lunch", "Dinner", etc… */ - @Json(name = "event_description") val description: String? = null, - @Json(name = "start") val startTime: LocalDateTime? = null, - @Json(name = "end") val endTime: LocalDateTime? = null, - @Json(name = "menu") val menu: MutableList? = null -) + val type: String? = null, + val startTimestamp: LocalDateTime? = null, + val endTimestamp: LocalDateTime? = null, + val upvotes: Int? = null, + val downvotes: Int? = null, + val createdAt: LocalDateTime? = null, + val eateryId: Int? = null, + val menu: MutableList? = null, + + ) @JsonClass(generateAdapter = true) data class MenuCategory( - @Json(name = "id") val id: Int? = null, - @Json(name = "category") val category: String? = null, - @Json(name = "event") val event: Int? = null, - @Json(name = "items") val items: List? = null + val id: Int? = null, + val name: String? = null, + val createdAt: LocalDateTime? = null, + val eventId: Int? = null, + val items: List? = null, ) @JsonClass(generateAdapter = true) data class MenuItem( - @Json(name = "id") val id: Int? = null, - @Json(name = "category") val category: Int? = null, - @Json(name = "name") val name: String? = null, + val id: Int? = null, + val name: String? = null, + val basePrice: Double? = null, + val createdAt: LocalDateTime? = null, + val categoryId: Int? = null, + val dietaryPreferences: List? = null, + val allergens: List? = null ) data class EateryStatus( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index 7f0d8a54..a6595796 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -5,32 +5,89 @@ package com.cornellappdev.android.eatery.data.models import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) +data class DeviceId( + @Json(name = "deviceUuid") val deviceId: String +) + +@JsonClass(generateAdapter = true) +data class AuthTokens( + @Json(name = "accessToken") val accessToken: String? = null, + @Json(name = "refreshToken") val refreshToken: String? = null +) + +@JsonClass(generateAdapter = true) +data class RefreshRequest( + @Json(name = "deviceUuid") val deviceId: String, + @Json(name = "refreshToken") val refreshToken: String +) + +@JsonClass(generateAdapter = true) +data class FcmToken( + @Json(name = "token") val fcmToken: String +) + +@JsonClass(generateAdapter = true) +data class FavoriteItem( + @Json(name = "name") val item: String +) + +@JsonClass(generateAdapter = true) +data class FavoriteEatery( + @Json(name = "eateryId") val eateryId: Int +) + +@JsonClass(generateAdapter = true) +data class FavoritesResponse( + @Json(name = "matches") val matches: List? = null +) + +@JsonClass(generateAdapter = true) +data class Match( + @Json(name = "eateryName") val eateryName: String? = null, + @Json(name = "items") val items: List? = null +) + +@JsonClass(generateAdapter = true) +data class Item( + @Json(name = "name") val name: String? = null, + @Json(name = "events") val events: List? = null +) + @JsonClass(generateAdapter = true) data class User( @Json(name = "favorite_eateries") val favoriteEateries: List = emptyList(), @Json(name = "favorite_items") val favoriteItems: List = emptyList(), - @Json(name = "brb_balance") val brbBalance: Double = 0.0, - @Json(name = "city_bucks_balance") val cityBucksBalance: Double = 0.0, - @Json(name = "laundry_balance") val laundryBalance: Double = 0.0, - @Json(name = "transactions") val transactions: List? = listOf(), + @Json(name = "brb_balance") val brbBalance: Double? = null, + @Json(name = "city_bucks_balance") val cityBucksBalance: Double? = null, + @Json(name = "laundry_balance") val laundryBalance: Double? = null, + @Json(name = "transactions") val transactions: List? = emptyList(), @Json(name = "meal_swipes") val mealSwipes: Int? = null // todo - backend should make this ) @JsonClass(generateAdapter = true) data class LoginRequest( - @Json(name = "device_id") val deviceId: String = "", - @Json(name = "fcm_token") val fcmToken: String = "", - @Json(name = "pin") val pin: Int = 0 + @Json(name = "pin") val pin: Int, + @Json(name = "sessionId") val sessionId: String, +) + +@JsonClass(generateAdapter = true) +data class LoginPIN( + @Json(name = "pin") val pin: Int ) @JsonClass(generateAdapter = true) -data class AuthorizedUser( - @Json(name = "id") val id: Long = 0, - @Json(name = "device_id") val deviceId: String = "", - @Json(name = "fcm_token") val fcmToken: String = "", - @Json(name = "pin") val pin: Int = 0 +data class SessionID( + @Json(name = "sessionId") val sessionId: String? = null ) +@JsonClass(generateAdapter = true) +data class Financials( + @Json(name = "accounts") val accounts: Accounts? = null, + @Json(name = "transactions") val transactions: Transactions? = null +) + + @JsonClass(generateAdapter = true) data class Accounts( @Json(name = "brb") val brbBalance: Account? = null, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index abcb661d..71b2a9a5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -3,6 +3,7 @@ package com.cornellappdev.android.eatery.data.repositories import com.cornellappdev.android.eatery.data.NetworkApi import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse.Success import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -23,9 +24,6 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { private suspend fun getEatery(eateryId: Int): Eatery = networkApi.fetchEatery(eateryId = eateryId.toString()) - private suspend fun getHomeEateries(): List = - networkApi.fetchHomeEateries() - private val _eateryFlow: MutableStateFlow>> = MutableStateFlow(EateryApiResponse.Pending) @@ -34,14 +32,6 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { */ val eateryFlow = _eateryFlow.asStateFlow() - private val _homeEateryFlow: MutableStateFlow>> = - MutableStateFlow(EateryApiResponse.Pending) - - /** - * A [StateFlow] emitting [EateryApiResponse]s for lists of home eateries. - */ - val homeEateryFlow = _homeEateryFlow.asStateFlow() - /** * A map from eatery ids to the states representing their API loading calls. */ @@ -53,15 +43,10 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { pingEateries() } - fun pingEateries() { - pingAllEateries() - pingHomeEateries() - } - /** * Makes a new call to backend for all the eatery data. */ - private fun pingAllEateries() { + fun pingEateries() { _eateryFlow.value = EateryApiResponse.Pending eateryApiCache.update { map -> map.mapValues { EateryApiResponse.Pending } @@ -70,10 +55,10 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { CoroutineScope(Dispatchers.IO).launch { try { val eateries = getAllEateries() - _eateryFlow.value = EateryApiResponse.Success(eateries) + _eateryFlow.value = Success(eateries) eateryApiCache.update { eateries.filter { it.id != null } - .associate { it.id!! to EateryApiResponse.Success(it) } + .associate { it.id!! to Success(it) } .withDefault { EateryApiResponse.Error } } } catch (_: Exception) { @@ -85,21 +70,6 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { } } - /** - * Makes a new call to backend for all the abridged home eatery data. - */ - private fun pingHomeEateries() { - _homeEateryFlow.value = EateryApiResponse.Pending - CoroutineScope(Dispatchers.IO).launch { - try { - val eateries = getHomeEateries() - _homeEateryFlow.value = EateryApiResponse.Success(eateries) - } catch (_: Exception) { - _homeEateryFlow.value = EateryApiResponse.Error - } - } - } - /** * Makes a new call to backend for the specified eatery. After calling, * `eateryApiCache[eateryId]` is guaranteed to contain a state actively loading that eatery's @@ -112,7 +82,7 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { CoroutineScope(Dispatchers.IO).launch { try { val eatery = getEatery(eateryId = eateryId) - updateCache(eateryId, EateryApiResponse.Success(eatery)) + updateCache(eateryId, Success(eatery)) } catch (_: Exception) { updateCache(eateryId, EateryApiResponse.Error) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index b3d1d9fa..93a5a600 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -2,8 +2,6 @@ package com.cornellappdev.android.eatery.data.repositories import androidx.datastore.core.DataStore import com.cornellappdev.android.eatery.UserPreferences -import com.cornellappdev.android.eatery.util.Constants.PASSWORD_ALIAS -import com.cornellappdev.android.eatery.util.encryptData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -13,30 +11,14 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton -// TODO: Add flow for favorites map. Wherever filtering by favorites are needed, read from this -// flow, and combine to filter the eateries out with the latest favorites. @Singleton class UserPreferencesRepository @Inject constructor( private val userPreferencesStore: DataStore, ) { private val userPreferencesFlow: Flow = userPreferencesStore.data - - /** - * A flow automatically emitting maps indicating whether particular Eateries are favorited. - */ - val favoritesFlow: StateFlow> = userPreferencesFlow.map { prefs -> - prefs.favoritesMap - }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, mapOf()) - - val favoriteItemsFlow: StateFlow> = - userPreferencesFlow.map { prefs -> - prefs.itemFavoritesMap - }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, mapOf()) - val recentSearchesFlow: StateFlow> = userPreferencesFlow.map { prefs -> prefs.recentSearchesList }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, listOf()) @@ -53,51 +35,6 @@ class UserPreferencesRepository @Inject constructor( } } - /** - * Asynchronously sets the indicated eatery id as favorite or not. - */ - fun setFavorite(eateryId: Int, isFavorite: Boolean) { - CoroutineScope(Dispatchers.IO).launch { - userPreferencesStore.updateData { currentPreferences -> - // There's no set data structure for protobuffs, so if the ID isn't in the map then - // it isn't a favorite (hence the removal instead of making false) - if (isFavorite) { - currentPreferences.toBuilder().putFavorites(eateryId, true).build() - } else { - currentPreferences.toBuilder().removeFavorites(eateryId).build() - } - } - } - } - - suspend fun toggleFavoriteMenuItem(menuItem: String) { - userPreferencesStore.updateData { currentPreferences -> - val isFavorite = currentPreferences.itemFavoritesMap[menuItem] == true - if (!isFavorite) { - currentPreferences.toBuilder().putItemFavorites(menuItem, true).build() - } else { - currentPreferences.toBuilder().removeItemFavorites(menuItem).build() - } - } - } - - suspend fun saveLoginInfo(username: String, password: String) { - userPreferencesStore.updateData { currentPreferences -> - currentPreferences.toBuilder() - .setUsername(username) - .setPassword(encryptData(PASSWORD_ALIAS, password)) - .build() - } - } - - suspend fun setIsLoggedIn(isLoggdIn: Boolean) { - userPreferencesStore.updateData { currentPreferences -> - currentPreferences.toBuilder() - .setIsLoggedIn(isLoggdIn) - .build() - } - } - suspend fun setAnalyticsDisabled(analyticsDisabled: Boolean) { userPreferencesStore.updateData { currentPreferences -> currentPreferences.toBuilder() @@ -120,25 +57,56 @@ class UserPreferencesRepository @Inject constructor( suspend fun getNotificationFlowCompleted(): Boolean = userPreferencesFlow.first().notificationFlowCompleted - suspend fun getIsLoggedIn(): Boolean = - userPreferencesFlow.first().isLoggedIn - suspend fun getAnalyticsDisabled(): Boolean = userPreferencesFlow.first().analyticsDisabled - suspend fun fetchLoginInfo(): Pair = - Pair(userPreferencesFlow.first().username, userPreferencesFlow.first().password) - - suspend fun setDeviceId(deviceId: java.util.UUID) { + private suspend fun setPref(setter: UserPreferences.Builder.() -> UserPreferences.Builder) { userPreferencesStore.updateData { currentPreferences -> currentPreferences.toBuilder() - .setDeviceId(deviceId.toString()) + .setter() .build() } } + suspend fun setDeviceId(deviceId: java.util.UUID) { + setPref { setDeviceId(deviceId.toString()) } + } + + private fun getStringPref(s: String?): String? { + return if (s.isNullOrEmpty()) null else s + } + suspend fun getDeviceId(): String? { - val id: String? = userPreferencesFlow.firstOrNull()?.deviceId - return if (id.isNullOrEmpty()) null else id + return getStringPref(userPreferencesFlow.firstOrNull()?.deviceId) + } + + suspend fun getAccessToken(): String? { + return getStringPref(userPreferencesFlow.firstOrNull()?.accessToken) + } + + suspend fun setAccessToken(accessToken: String) { + setPref { setAccessToken(accessToken) } + } + + suspend fun getRefreshToken(): String? { + return getStringPref(userPreferencesFlow.firstOrNull()?.refreshToken) + } + + suspend fun setRefreshToken(refreshToken: String) { + setPref { setRefreshToken(refreshToken) } + } + + suspend fun getIsLoggedIn(): Boolean = userPreferencesFlow.firstOrNull()?.isLoggedIn ?: false + + suspend fun setIsLoggedIn(loggedIn: Boolean) = setPref { setIsLoggedIn(loggedIn) } + + suspend fun getPin(): Int = userPreferencesFlow.first().pin + + suspend fun setPin(pin: Int) { + setPref { setPin(pin) } + } + + suspend fun setSessionId(sessionId: String) { + setPref { setSessionId(sessionId) } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 930f0983..ff08d0a8 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -1,17 +1,28 @@ package com.cornellappdev.android.eatery.data.repositories import com.cornellappdev.android.eatery.data.NetworkApi +import com.cornellappdev.android.eatery.data.models.DeviceId +import com.cornellappdev.android.eatery.data.models.FavoriteEatery +import com.cornellappdev.android.eatery.data.models.FavoriteItem +import com.cornellappdev.android.eatery.data.models.Financials +import com.cornellappdev.android.eatery.data.models.LoginPIN import com.cornellappdev.android.eatery.data.models.LoginRequest +import com.cornellappdev.android.eatery.data.models.RefreshRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody import com.cornellappdev.android.eatery.data.models.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.random.Random @Singleton -class UserRepository @Inject constructor(private val networkApi: NetworkApi) { +class UserRepository @Inject constructor( + private val networkApi: NetworkApi, + val userPreferencesRepository: UserPreferencesRepository +) { private val _loadedUser: MutableStateFlow = MutableStateFlow(null) /** @@ -19,45 +30,208 @@ class UserRepository @Inject constructor(private val networkApi: NetworkApi) { */ val loadedUser: StateFlow = _loadedUser.asStateFlow() + private val _favoritesEateriesFlow: MutableStateFlow> = + MutableStateFlow(emptyList()) + + /** + * A [StateFlow] emitting a list of the names of the user's favorite eateries. + */ + val favoriteEateriesFlow: StateFlow> = _favoritesEateriesFlow.asStateFlow() + private val _favoriteItemsFlow: MutableStateFlow> = + MutableStateFlow(emptyList()) + + /** + * A [StateFlow] emitting a map from menu items to whether they are favorited. + */ + val favoriteItemsFlow: StateFlow> = _favoriteItemsFlow.asStateFlow() + + suspend fun hasLaunchedBefore(): Boolean = userPreferencesRepository.getDeviceId() != null + + suspend fun getDeviceId(): String { + val deviceId = userPreferencesRepository.getDeviceId() + if (deviceId != null) return deviceId + + // first launch + val uuid = UUID.randomUUID() + userPreferencesRepository.setDeviceId(uuid) + return uuid.toString() + } + + // called on first app launch + suspend fun registerDevice() { + val deviceId = UUID.randomUUID() + userPreferencesRepository.setDeviceId(deviceId) + } + + // called on app launch + suspend fun getTokens() { + val deviceId = + userPreferencesRepository.getDeviceId() ?: throw Exception("Device not registered") + val response = networkApi.verifyToken(DeviceId(deviceId)) + val accessToken = response.accessToken + val refreshToken = response.refreshToken + if (accessToken != null) { + userPreferencesRepository.setAccessToken(accessToken) + } else { + throw Exception("Access token is null") + } + if (refreshToken != null) { + userPreferencesRepository.setRefreshToken(refreshToken) + } else { + throw Exception("Refresh token is null") + } + } + + suspend fun updateFavorites() { + val accessPhrase = getAccessToken() + val favoritesResponse = tryRequest { + networkApi.getFavoriteMatches(accessToken = accessPhrase) + } + val matches = favoritesResponse.matches ?: return + _favoritesEateriesFlow.value = matches.mapNotNull { it.eateryName } + _favoriteItemsFlow.value = run { + val items: MutableList = mutableListOf() + matches.forEach { (_, eateryItems) -> + if (eateryItems != null) { + items.addAll(eateryItems.mapNotNull { it.name }) + } + } + items.toList() + } + } suspend fun sendReport(issue: String, report: String, eateryID: Int?): Any = - networkApi.sendReport( - report = ReportSendBody( - eatery = eateryID, - content = "$issue: $report" + tryRequest { + networkApi.sendReport( + report = ReportSendBody( + eatery = eateryID, + content = "$issue: $report" + ) ) + } + + suspend fun addFavoriteItem(name: String) = tryRequest { + networkApi.addFavoriteItem( + accessToken = getAccessToken(), + item = FavoriteItem(item = name) ) + } - /** - * Fetches the user from backend. - */ - suspend fun getUser( - sessionId: String, - deviceId: String, - fcmToken: String - ): User { - val bearerToken = "Bearer $sessionId" - val authorizedUser = networkApi.authorizeUser( - sessionId = bearerToken, - loginRequest = LoginRequest(deviceId = deviceId, pin = 1234, fcmToken = fcmToken) + suspend fun removeFavoriteItem(name: String) = tryRequest { + networkApi.deleteFavoriteItem( + accessToken = getAccessToken(), + item = FavoriteItem(name) ) - // load accounts in case needed - networkApi.getUserAccounts( - sessionId = bearerToken, - user = authorizedUser + } + + suspend fun addFavoriteEatery(id: Int) = tryRequest { + networkApi.addFavoriteEatery( + accessToken = getAccessToken(), + eatery = FavoriteEatery(id), + ) + } + + suspend fun removeFavoriteEatery(id: Int) = tryRequest { + networkApi.deleteFavoriteEatery( + accessToken = getAccessToken(), + eatery = FavoriteEatery(id) ) - val transactions = networkApi.getUserTransactions( - sessionId = bearerToken, - user = authorizedUser - ).transactions - val userWithData = networkApi.getUserData( - id = authorizedUser.id - ).copy(transactions = transactions) - _loadedUser.value = userWithData - return userWithData - } - - fun logout() { + } + + suspend fun linkGETAccount(sessionId: String) { + userPreferencesRepository.setSessionId(sessionId) + val pin = Random.nextInt(10000) + userPreferencesRepository.setPin(pin) + tryRequest { + networkApi.authorizeUser( + accessToken = getAccessToken(), + loginRequest = LoginRequest(pin, sessionId) + ) + } + } + + suspend fun getFinancials(): Financials = tryRequest { + var financials: Financials + try { + financials = networkApi.getFinancials( + accessToken = getAccessToken() + ) + } catch (_: Exception) { + val pin = + userPreferencesRepository.getPin() + refreshLogin(pin = pin) + financials = networkApi.getFinancials(accessToken = getAccessToken()) + } + financials + } + + suspend fun isLoggedIn(): Boolean = userPreferencesRepository.getIsLoggedIn() + + /** + * Refreshes GET sessionID and returns it. + */ + suspend fun refreshLogin(pin: Int) = tryRequest { + val newSessionId = networkApi.refreshAuthorizedUser( + accessToken = getAccessToken(), + loginPIN = LoginPIN(pin) + ).toString() + userPreferencesRepository.setSessionId(newSessionId) + } + + suspend fun logout() { _loadedUser.value = null + userPreferencesRepository.setSessionId("") + userPreferencesRepository.setIsLoggedIn(false) + } + + suspend fun hasOnboarded(): Boolean = userPreferencesRepository.getHasOnboarded() + + /** + * Tries to make the given request, and if it fails, refreshes tokens and tries again. + */ + private suspend fun tryRequest(request: suspend () -> T): T { + try { + return request() + } catch (_: Exception) { + try { + refreshTokens() + return request() + } catch (e: Exception) { + throw e + } + } } + + /** + * Gets refresh token assuming device has been registered + */ + private suspend fun refreshTokens() { + val deviceId = getDeviceId() + val refreshToken = userPreferencesRepository.getRefreshToken()!! + val tokens = networkApi.refreshToken( + RefreshRequest( + deviceId = deviceId, + refreshToken = refreshToken + ) + ) + val accessToken = tokens.accessToken + val newRefreshToken = tokens.refreshToken + if (accessToken != null) { + userPreferencesRepository.setAccessToken(accessToken) + } else { + throw Exception("Access token is null") + } + if (newRefreshToken != null) { + userPreferencesRepository.setRefreshToken(newRefreshToken) + } else { + throw Exception("Refresh token is null") + } + } + + /** + * Gets access token with Bearer prefix assuming device has been registered + */ + private suspend fun getAccessToken(): String = + "Bearer ${userPreferencesRepository.getAccessToken()!!}" + } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/di/NetworkingModule.kt b/app/src/main/java/com/cornellappdev/android/eatery/di/NetworkingModule.kt index b363bf45..8cc634dd 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/di/NetworkingModule.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/di/NetworkingModule.kt @@ -2,8 +2,16 @@ package com.cornellappdev.android.eatery.di import android.util.Log import com.cornellappdev.android.eatery.BuildConfig -import com.cornellappdev.android.eatery.data.* +import com.cornellappdev.android.eatery.data.AccountTypeAdapter +import com.cornellappdev.android.eatery.data.DateAdapter +import com.cornellappdev.android.eatery.data.DateTimeAdapter +import com.cornellappdev.android.eatery.data.NetworkApi +import com.cornellappdev.android.eatery.data.ReportAdapter +import com.cornellappdev.android.eatery.data.TimestampAdapter +import com.cornellappdev.android.eatery.data.TransactionTypeAdapter +import com.cornellappdev.android.eatery.data.models.PaymentMethod import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.EnumJsonAdapter import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import dagger.Module import dagger.Provides @@ -43,6 +51,10 @@ object NetworkModule { .add(AccountTypeAdapter()) .add(KotlinJsonAdapterFactory()) .add(ReportAdapter()) + .add( + PaymentMethod::class.java, EnumJsonAdapter.create(PaymentMethod::class.java) + .withUnknownFallback(PaymentMethod.UNKNOWN) + ) .build() @Singleton diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/AlertsSection.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/AlertsSection.kt index 4ed442b8..ea4120f0 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/AlertsSection.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/AlertsSection.kt @@ -35,7 +35,7 @@ fun AlertsSection(eatery: Eatery) { ) { eatery.alerts?.forEach { - if (!it.description.isNullOrBlank() && it.startTime?.isBefore(LocalDateTime.now()) == true && it.endTime?.isAfter( + if (!it.description.isNullOrBlank() && it.startTimestamp?.isBefore(LocalDateTime.now()) == true && it.endTimestamp?.isAfter( LocalDateTime.now() ) == true ) Surface( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryDetailsStickyHeader.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryDetailsStickyHeader.kt index efc257c2..726e5f58 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryDetailsStickyHeader.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryDetailsStickyHeader.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.Event import com.cornellappdev.android.eatery.data.models.MenuCategory import com.cornellappdev.android.eatery.ui.theme.GrayFive @@ -41,7 +40,6 @@ import kotlinx.coroutines.launch @Composable fun EateryDetailsStickyHeader( nextEvent: Event?, - eatery: Eatery, filterText: String, fullMenuList: MutableList, listState: LazyListState, @@ -108,7 +106,7 @@ fun EateryDetailsStickyHeader( nextEvent?.menu?.forEach { category -> item { - val categoryIndex = fullMenuList.indexOf(category.category) + val categoryIndex = fullMenuList.indexOf(category.name) val isHighlighted = highlightCategory( category, listState, @@ -117,7 +115,7 @@ fun EateryDetailsStickyHeader( startIndex ) CategoryItem( - category.category ?: "Category", + category.name ?: "Category", isHighlighted, ) { onItemClick(categoryIndex) } } @@ -203,15 +201,15 @@ fun highlightCategory( if (firstMenuItemIndex >= 0 && firstMenuItemIndex < fullMenuList.size) { val item = fullMenuList[firstMenuItemIndex] - val isCategoryName = nextEvent?.menu?.any { it.category == item } ?: false + val isCategoryName = nextEvent?.menu?.any { it.name == item } ?: false if (isCategoryName) { - return category.category == item + return category.name == item } else { for (i in firstMenuItemIndex - 1 downTo 0) { val previousItem = fullMenuList[i] - if (nextEvent?.menu?.any { it.category == previousItem } == true) { - return category.category == previousItem + if (nextEvent?.menu?.any { it.name == previousItem } == true) { + return category.name == previousItem } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt index cee32ae0..61463cbf 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt @@ -95,9 +95,9 @@ fun EateryMenusBottomSheet( } val selectedDayOfWeek = DayOfWeek.of(dayWeeks[currSelectedDay]) - val mealTypes: List>? = eatery.getTypeMeal(selectedDayOfWeek) + val mealTypes: List> = eatery.getTypeMeal(selectedDayOfWeek) var selectedMealType by remember { - mutableStateOf(mealTypes?.get(mealType)?.first ?: "") + mutableStateOf(mealTypes[mealType].first) } Card( @@ -147,7 +147,7 @@ fun EateryMenusBottomSheet( days = days, onClick = { i -> currSelectedDay = i - selectedMealType = mealTypes?.firstOrNull()?.first ?: "" + selectedMealType = mealTypes.firstOrNull()?.first ?: "" }, modifier = Modifier.padding(bottom = 12.dp), closedDays = closedDaysStrings, @@ -162,73 +162,67 @@ fun EateryMenusBottomSheet( .padding(top = 12.dp, bottom = 12.dp) .fillMaxWidth() ) { - if (mealTypes != null && mealTypes.size > 1) { + if (mealTypes.size > 1) { mealTypes.forEachIndexed { index, (description, duration) -> - if (description != null && duration != null) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = description, - fontSize = 18.sp, - fontWeight = FontWeight(600), - color = Color.Black, - modifier = Modifier.padding(bottom = 2.dp) - ) - Text( - text = duration, - fontSize = 12.sp, - fontWeight = FontWeight(600), - color = Color.Gray - ) - } - IconButton(onClick = { selectedMealType = description }) { - if (selectedMealType == description) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(26.dp) - .background(Color.Black, CircleShape) - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = Color.White, - modifier = Modifier.fillMaxSize(0.7f) - ) - } - } else { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(26.dp) - .background(Color.White, CircleShape) - .border(2.dp, Color.Black, CircleShape) - ) { - } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = description, + fontSize = 18.sp, + fontWeight = FontWeight(600), + color = Color.Black, + modifier = Modifier.padding(bottom = 2.dp) + ) + Text( + text = duration, + fontSize = 12.sp, + fontWeight = FontWeight(600), + color = Color.Gray + ) + } + IconButton(onClick = { selectedMealType = description }) { + if (selectedMealType == description) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(26.dp) + .background(Color.Black, CircleShape) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.fillMaxSize(0.7f) + ) + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(26.dp) + .background(Color.White, CircleShape) + .border(2.dp, Color.Black, CircleShape) + ) { } } - - } - if (mealTypes.lastIndex != index) { - Spacer( - modifier = Modifier - .padding(top = 12.dp, bottom = 12.dp) - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) } } + if (mealTypes.lastIndex != index) { + Spacer( + modifier = Modifier + .padding(top = 12.dp, bottom = 12.dp) + .fillMaxWidth() + .height(1.dp) + .background(GrayZero, CircleShape) + ) + } } } } - -// Spacer(modifier = Modifier.height(12.dp)) - // Show menu and reset menu buttons Column( modifier = Modifier.padding(bottom = 12.dp), @@ -237,12 +231,10 @@ fun EateryMenusBottomSheet( Button( onClick = { selectedDay = currSelectedDay - if (mealTypes != null) { - onShowMenuClick( - currSelectedDay, - selectedMealType, - mealTypes.indexOfFirst { it.first == selectedMealType }) - } + onShowMenuClick( + currSelectedDay, + selectedMealType, + mealTypes.indexOfFirst { it.first == selectedMealType }) onDismiss() }, modifier = Modifier @@ -259,7 +251,8 @@ fun EateryMenusBottomSheet( color = Color.White ) } - ClickableText(modifier = Modifier.padding(top = 12.dp), + ClickableText( + modifier = Modifier.padding(top = 12.dp), text = AnnotatedString("Reset"), style = TextStyle( fontSize = 14.sp, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/PaymentWidgets.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/PaymentWidgets.kt index 5cf5118d..50889e1a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/PaymentWidgets.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/PaymentWidgets.kt @@ -33,21 +33,21 @@ fun PaymentWidgets(eatery: Eatery, modifier: Modifier = Modifier, onClick: () -> modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.IconSpacing) ) { - if (eatery.paymentAcceptsMealSwipes == true) { + if (eatery.acceptsMealSwipes()) { Icon( painter = painterResource(id = R.drawable.ic_payment_swipes), contentDescription = "Accepts Swipes", tint = EateryBlue ) } - if (eatery.paymentAcceptsBrbs == true) { + if (eatery.acceptsBRB()) { Icon( painter = painterResource(id = R.drawable.ic_payment_brbs), contentDescription = "Accepts BRBs", tint = Red ) } - if (eatery.paymentAcceptsCash == true) { + if (eatery.acceptsCash()) { Icon( painter = painterResource(id = R.drawable.ic_payment_cash), contentDescription = "Accepts Cash", diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt index ad6354ed..49920c6a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt @@ -320,9 +320,9 @@ fun EateryCardSecondaryHeader(eatery: Eatery, style: EateryCardStyle = EateryCar Text( modifier = Modifier.padding(top = 2.dp), text = - if (openUntil == null) "Closed" - else if (eatery.isClosingSoon()) "Closing at $openUntil" - else ("Open until $openUntil"), + if (openUntil == null) "Closed" + else if (eatery.isClosingSoon()) "Closing at $openUntil" + else ("Open until $openUntil"), style = EateryBlueTypography.subtitle2, color = if (openUntil == null) Red else if (eatery.isClosingSoon()) Yellow @@ -382,7 +382,7 @@ fun DotSeparator() { @Composable fun EateryMenuSummary(eatery: Eatery) { - if (eatery.paymentAcceptsMealSwipes == true) { + if (eatery.acceptsMealSwipes()) { DotSeparator() Text( text = "Meal swipes allowed", @@ -390,8 +390,8 @@ fun EateryMenuSummary(eatery: Eatery) { color = EateryBlue, style = EateryBlueTypography.subtitle2 ) - } else if (eatery.paymentAcceptsBrbs == false && - eatery.paymentAcceptsCash == true + } else if (!eatery.acceptsBRB() && + (eatery.acceptsCash() || eatery.acceptsCard()) ) { DotSeparator() Text( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt index ade7f93d..348a9e95 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt @@ -28,7 +28,7 @@ sealed class Filter(open val text: String) { val eatery = checkNotNull(filterData.eatery) return eatery.events?.asSequence()?.filter { - it.endTime?.let { end -> + it.endTimestamp?.let { end -> end < LocalDateTime.now().withHour(23).withMinute(59) } == true }?.flatMap { it.menu ?: emptyList() } @@ -58,18 +58,18 @@ sealed class Filter(open val text: String) { data object North : FromEateryFilter(text = "North") { override fun passesEateryFilter(eatery: Eatery): Boolean = - eatery.campusArea == "North" + eatery.campusArea == "NORTH" } data object West : FromEateryFilter(text = "West") { override fun passesEateryFilter(eatery: Eatery): Boolean = - eatery.campusArea == "West" + eatery.campusArea == "WEST" } data object Central : FromEateryFilter(text = "Central") { override fun passesEateryFilter(eatery: Eatery): Boolean = - eatery.campusArea == "Central" + eatery.campusArea == "CENTRAL" } data object Under10 : FromEateryFilter(text = "Under 10 min") { @@ -79,17 +79,17 @@ sealed class Filter(open val text: String) { data object Swipes : FromEateryFilter(text = "Swipes") { override fun passesEateryFilter(eatery: Eatery): Boolean = - eatery.paymentAcceptsMealSwipes == true + eatery.acceptsMealSwipes() } data object BRB : FromEateryFilter(text = "BRBs") { override fun passesEateryFilter(eatery: Eatery): Boolean = - eatery.paymentAcceptsBrbs == true + eatery.acceptsBRB() } data object Cash : FromEateryFilter(text = "Cash") { override fun passesEateryFilter(eatery: Eatery): Boolean = - eatery.paymentAcceptsCash == true + eatery.acceptsCash() } } @@ -166,8 +166,8 @@ fun List.updateFilters(newFilter: Filter): List { * endTimes: Float that represents average end time for meal out of 24 */ enum class MealFilter(val text: List, val endTimes: Float) { - BREAKFAST(listOf("Breakfast", "Brunch"), 10.5f), - LUNCH(listOf("Lunch", "Brunch", "Late Lunch"), 16f), - DINNER(listOf("Dinner"), 20.5f), - LATE_DINNER(listOf("Late Night"), 22.5f); + BREAKFAST(listOf("BREAKFAST", "BRUNCH"), 10.5f), + LUNCH(listOf("LUNCH", "BRUNCH", "LATE_LUNCH"), 16f), + DINNER(listOf("DINNER"), 20.5f), + LATE_DINNER(listOf("LATE_NIGHT"), 22.5f); } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/MenuItems.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/MenuItems.kt index c6b63cae..0dfa9241 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/MenuItems.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/MenuItems.kt @@ -25,7 +25,7 @@ data class MenuCategoryViewState( val items: List ) { fun toMenuCategory() = MenuCategory( - category = category, + name = category, items = items.map { it.toMenuItem() } ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt index 9493b1ae..75e520d1 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt @@ -132,7 +132,8 @@ fun MealBottomSheet( .height(1.dp) .background(GrayZero, CircleShape) ) - Row(horizontalArrangement = Arrangement.SpaceBetween, + Row( + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() .clickable { @@ -182,7 +183,8 @@ fun MealBottomSheet( .height(1.dp) .background(GrayZero, CircleShape) ) - Row(horizontalArrangement = Arrangement.SpaceBetween, + Row( + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() .clickable { @@ -230,7 +232,8 @@ fun MealBottomSheet( .height(1.dp) .background(GrayZero, CircleShape) ) - Row(horizontalArrangement = Arrangement.SpaceBetween, + Row( + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() .clickable { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt index 5a771cd3..0839de23 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.NavType @@ -49,7 +48,6 @@ import com.cornellappdev.android.eatery.ui.screens.SettingsScreen import com.cornellappdev.android.eatery.ui.screens.SupportScreen import com.cornellappdev.android.eatery.ui.screens.UpcomingMenuScreen import com.cornellappdev.android.eatery.ui.theme.EateryBlue -import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel import com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.composable import com.google.accompanist.navigation.animation.rememberAnimatedNavController @@ -165,7 +163,6 @@ fun SetupNavHost( hasOnboarded: Boolean, navController: NavHostController, showBottomBar: MutableState, - loginViewModel: LoginViewModel = hiltViewModel(), ) { AppStoreRatingPopup(navigateToSupport = { navController.navigate(Routes.SUPPORT.route) }) @@ -297,10 +294,6 @@ fun SetupNavHost( }, exitTransition = { webViewEnabled = false - if (loginViewModel.state.value is LoginViewModel.State.Login) { - // not yet logged in, so reset. - loginViewModel.resetLogin() - } fadeOut( animationSpec = tween(durationMillis = 500) ) @@ -308,13 +301,11 @@ fun SetupNavHost( // need this for when user navigates from profile to itself // since no guarantee of order between enterTransition and exitTransition webViewEnabled = true + ProfileScreen( - loginViewModel = loginViewModel, onSettingsClicked = { navController.navigate(Routes.SETTINGS.route) }, webViewEnabled = true, - onBackClick = { - navController.popBackStack() - } + onBackClick = navController::popBackStack ) } composable( @@ -349,7 +340,7 @@ fun SetupNavHost( } } ), - loginViewModel = loginViewModel +// loginViewModel = loginViewModel ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt index 9fe4aa7d..814011b3 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt @@ -241,7 +241,7 @@ private fun MenuPager( val currentEvent = events[page] val fullMenuList = mutableListOf() currentEvent?.menu?.forEach { category -> - category.category?.let { fullMenuList.add(it) } + category.name?.let { fullMenuList.add(it) } category.items?.forEach { item -> item.name?.let { fullMenuList.add(it) } } @@ -312,7 +312,6 @@ private fun MenuPager( EateryDetailsStickyHeader( currentEvent, - eateries[page], "", fullMenuList, listState, @@ -347,7 +346,7 @@ private fun MenuPager( currentEvent.menu?.forEach { category -> item { Text( - text = category.category ?: "Category", + text = category.name ?: "Category", style = EateryBlueTypography.h5, modifier = Modifier.padding( horizontal = 16.dp, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt index e9ca70ea..936f660c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt @@ -319,9 +319,9 @@ fun EateryDetailScreen( paymentMethods.apply { - if (eatery.paymentAcceptsCash == true) add(PaymentMethodsAvailable.CASH) - if (eatery.paymentAcceptsBrbs == true) add(PaymentMethodsAvailable.BRB) - if (eatery.paymentAcceptsMealSwipes == true) add( + if (eatery.acceptsCash()) add(PaymentMethodsAvailable.CASH) + if (eatery.acceptsBRB()) add(PaymentMethodsAvailable.BRB) + if (eatery.acceptsMealSwipes()) add( PaymentMethodsAvailable.SWIPES ) } @@ -457,7 +457,7 @@ fun EateryDetailScreen( .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - if (eatery.paymentAcceptsMealSwipes == false) { + if (!eatery.acceptsMealSwipes()) { Button( onClick = { val getAppIntent = @@ -508,7 +508,7 @@ fun EateryDetailScreen( context.startActivity(mapIntent) }, shape = RoundedCornerShape(100), - modifier = if (eatery.paymentAcceptsMealSwipes == false) Modifier else Modifier + modifier = if (!eatery.acceptsMealSwipes()) Modifier else Modifier .fillMaxWidth() .padding(horizontal = 15.dp), colors = ButtonDefaults.buttonColors( @@ -700,7 +700,7 @@ fun EateryDetailScreen( ) } eatery.getTypeMeal(viewState.weekdayIndex.fromOffsetToDayOfWeek()) - .takeIf { it?.size?.let { s -> s > 1 } == true } + .takeIf { it.size > 1 } ?.map { it.first } ?.let { mealTypes -> item { @@ -825,7 +825,6 @@ fun EateryDetailScreen( ) EateryDetailsStickyHeader( nextEvent.toEvent(), - eatery, filterText, fullMenuList, listState, @@ -877,14 +876,14 @@ private fun LazyListScope.menuHeadingItem( .toReadableFullName(), style = EateryBlueTypography.h4, ) - if (nextEvent.startTime != null && nextEvent.endTime != null) { + if (nextEvent.startTimestamp != null && nextEvent.endTimestamp != null) { Text( text = "${ - nextEvent.startTime.format( + nextEvent.startTimestamp.format( DateTimeFormatter.ofPattern("h:mm a") ) } - ${ - nextEvent.endTime.format( + nextEvent.endTimestamp.format( DateTimeFormatter.ofPattern("h:mm a") ) }", diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt index dc8c57e8..61393cf4 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt @@ -136,7 +136,7 @@ fun FavoritesScreen( toggleEateryFilter = favoriteViewModel::toggleEateryFilter, toggleItemFilter = favoriteViewModel::toggleItemFilter, removeFavorite = favoriteViewModel::removeFavorite, - removeFavoriteMenuItem = favoriteViewModel::removeFavoriteMenuItem, + removeFavoriteMenuItem = favoriteViewModel::toggleFavoriteMenuItem, ) } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 260a1a18..7a7b734d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -226,10 +226,12 @@ fun HomeScreen( nearestEateries = nearestEateries, selectedFilters = filters, onFavoriteClick = { eatery, favorite -> - if (favorite) { - homeViewModel.addFavorite(eatery.id) - } else { - homeViewModel.removeFavorite(eatery.id) + if (eatery.id != null) { + if (favorite) { + homeViewModel.addFavoriteEatery(eatery.id) + } else { + homeViewModel.removeFavoriteEatery(eatery.id) + } } }, onFilterClicked = homeViewModel::onToggleFilterPressed, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 944e9cbb..53e4336a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.android.eatery.data.models.AccountBalances import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.data.models.TransactionAccountType @@ -16,7 +17,7 @@ import com.cornellappdev.android.eatery.util.EateryPreview @OptIn(ExperimentalAnimationApi::class) @Composable fun ProfileScreen( - loginViewModel: LoginViewModel, + loginViewModel: LoginViewModel = hiltViewModel(), onSettingsClicked: () -> Unit, webViewEnabled: Boolean, onBackClick: () -> Unit diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt index 865c3ea4..c09e3067 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.ui.components.settings.AppIconBottomSheet import com.cornellappdev.android.eatery.ui.components.settings.SettingsLineSeparator @@ -46,7 +47,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterialApi::class) @Composable fun SettingsScreen( - loginViewModel: LoginViewModel, + loginViewModel: LoginViewModel = hiltViewModel(), destinations: HashMap Unit> ) { // To sign out, setIsLoggedIn to false and transition back to profileView with autoLogin false diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt index 8faa8245..ed8059bf 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.repositories.EateryRepository -import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterData @@ -26,7 +25,6 @@ import javax.inject.Inject @HiltViewModel class CompareMenusBotViewModel @Inject constructor( - userPreferencesRepository: UserPreferencesRepository, eateryRepository: EateryRepository, private val userRepository: UserRepository, ) : ViewModel() { @@ -48,7 +46,7 @@ class CompareMenusBotViewModel @Inject constructor( private var firstEatery: Eatery? = null val eateryFlow: StateFlow>> = - eateryRepository.homeEateryFlow.map { apiResponse -> + eateryRepository.eateryFlow.map { apiResponse -> when (apiResponse) { is EateryApiResponse.Error -> EateryApiResponse.Error is EateryApiResponse.Pending -> EateryApiResponse.Pending @@ -66,10 +64,9 @@ class CompareMenusBotViewModel @Inject constructor( init { combine( eateryFlow, - userPreferencesRepository.favoritesFlow, filtersFlow, selectedEateriesFlow - ) { eateriesApiResponse, _, filters, selected -> + ) { eateriesApiResponse, filters, selected -> when (eateriesApiResponse) { is EateryApiResponse.Success -> { _compareMenusUiState.update { currentState -> diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt index fffca36c..7650b509 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.Event import com.cornellappdev.android.eatery.data.repositories.EateryRepository -import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.MenuCategoryViewState import com.cornellappdev.android.eatery.ui.components.general.MenuItemViewState @@ -34,7 +33,7 @@ sealed class EateryDetailViewState { val weekdayIndex: Int, ) : EateryDetailViewState() { val mealTypeIndex: Int = eatery.getTypeMeal(weekdayIndex.fromOffsetToDayOfWeek()) - ?.indexOfFirst { it.first == mealToShow.description }?.coerceAtLeast(0) ?: 0 + .indexOfFirst { it.first == mealToShow.description }.coerceAtLeast(0) } data class Error(val message: String) : EateryDetailViewState() @@ -48,9 +47,9 @@ data class MealViewState( val description: String?, ) { fun toEvent() = Event( - description = description, - startTime = startTime, - endTime = endTime, + type = description, + startTimestamp = startTime, + endTimestamp = endTime, menu = menu?.map { it.toMenuCategory() }?.toMutableList() ) } @@ -59,7 +58,6 @@ data class MealViewState( @HiltViewModel class EateryDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val userPreferencesRepository: UserPreferencesRepository, eateryRepository: EateryRepository, private val userRepository: UserRepository ) : ViewModel() { @@ -95,8 +93,8 @@ class EateryDetailViewModel @Inject constructor( */ private fun openEatery() { combine( - userPreferencesRepository.favoritesFlow, - userPreferencesRepository.favoriteItemsFlow, + userRepository.favoriteEateriesFlow, + userRepository.favoriteItemsFlow, eateryFlow, userSelectedMeal ) { favoriteEateries, favoriteItems, eatery, userSelectedMeal -> @@ -114,22 +112,22 @@ class EateryDetailViewModel @Inject constructor( EateryDetailViewState.Loaded( mealToShow = MealViewState( - currentMeal?.startTime, - currentMeal?.endTime, + currentMeal?.startTimestamp, + currentMeal?.endTimestamp, currentMeal?.menu?.map { menu -> MenuCategoryViewState( - menu.category ?: "", + menu.name ?: "", menu.items?.map { menuItem -> MenuItemViewState( item = menuItem, - isFavorite = favoriteItems[menuItem.name] == true + isFavorite = menuItem.name in favoriteItems ) } ?: emptyList() ) }, - description = currentMeal?.description + description = currentMeal?.type ), - isFavorite = favoriteEateries[eateryId] == true, + isFavorite = eatery.data.name in favoriteEateries, eatery = eatery.data, weekdayIndex = (it as? EateryDetailViewState.Loaded)?.weekdayIndex ?: 0 ) @@ -141,7 +139,13 @@ class EateryDetailViewModel @Inject constructor( fun toggleFavorite() { when (val eateryState = eateryDetailViewState.value) { is EateryDetailViewState.Loaded -> { - userPreferencesRepository.setFavorite(eateryId, !eateryState.isFavorite) + viewModelScope.launch { + if (eateryState.isFavorite) { + userRepository.removeFavoriteEatery(eateryId) + } else { + userRepository.addFavoriteEatery(eateryId) + } + } } else -> { @@ -155,7 +159,11 @@ class EateryDetailViewModel @Inject constructor( */ fun toggleFavoriteMenuItem(menuItem: String) { viewModelScope.launch { - userPreferencesRepository.toggleFavoriteMenuItem(menuItem) + if (menuItem in userRepository.favoriteItemsFlow.value) { + userRepository.removeFavoriteItem(menuItem) + } else { + userRepository.addFavoriteItem(menuItem) + } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt index 6721cd14..efc78a94 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt @@ -6,7 +6,7 @@ import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.EateryStatus import com.cornellappdev.android.eatery.data.repositories.EateryRepository -import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository +import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.Filter.FromEateryFilter import com.cornellappdev.android.eatery.ui.components.general.FilterData @@ -58,8 +58,8 @@ sealed class FavoritesScreenViewState { @HiltViewModel class FavoritesViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, - eateryRepository: EateryRepository + eateryRepository: EateryRepository, + private val userRepository: UserRepository ) : ViewModel() { private val selectedEateryFiltersFlow = MutableStateFlow>(emptyList()) private val selectedItemFiltersFlow = MutableStateFlow>(emptyList()) @@ -69,10 +69,10 @@ class FavoritesViewModel @Inject constructor( */ val favoritesScreenViewState: StateFlow = combine( eateryRepository.eateryFlow, - userPreferencesRepository.favoriteItemsFlow, + userRepository.favoriteItemsFlow, selectedEateryFiltersFlow, selectedItemFiltersFlow - ) { apiResponse, favoriteItemsMap, selectedEateryFilters, selectedItemFilters -> + ) { apiResponse, favoriteItems, selectedEateryFilters, selectedItemFilters -> when (apiResponse) { is EateryApiResponse.Error -> FavoritesScreenViewState.Error is EateryApiResponse.Pending -> FavoritesScreenViewState.Loading @@ -87,14 +87,12 @@ class FavoritesViewModel @Inject constructor( ) } - - val favoriteItems = favoriteItemsMap.keys.filter { favoriteItemsMap[it] == true } - val menuItemsToEateries: Map> = favoriteItems.associateWith { itemName -> allEateries.filter { eatery -> val todayEvents = eatery.events?.filter { - (it.endTime ?: LocalDateTime.MAX) < LocalDateTime.now().withHour(23) + (it.endTimestamp ?: LocalDateTime.MAX) < LocalDateTime.now() + .withHour(23) .withMinute(59) } todayEvents?.any { event -> @@ -130,7 +128,7 @@ class FavoritesViewModel @Inject constructor( event.menu?.any { it.items?.any { menuItem -> menuItem.name == itemName } == true } == true - }?.description + }?.type }.mapValues { mapEntry -> mapEntry.value.mapNotNull { eatery -> eatery.name } }.mapKeys { (key, _) -> @@ -166,8 +164,14 @@ class FavoritesViewModel @Inject constructor( else -> Int.MAX_VALUE } - fun removeFavoriteMenuItem(menuItemName: String) = viewModelScope.launch { - userPreferencesRepository.toggleFavoriteMenuItem(menuItemName) + fun toggleFavoriteMenuItem(menuItemName: String) = viewModelScope.launch { + viewModelScope.launch { + if (menuItemName in userRepository.favoriteItemsFlow.value) { + userRepository.removeFavoriteItem(menuItemName) + } else { + userRepository.addFavoriteItem(menuItemName) + } + } } fun toggleEateryFilter(filter: FromEateryFilter) { @@ -183,6 +187,10 @@ class FavoritesViewModel @Inject constructor( } fun removeFavorite(eateryId: Int?) { - if (eateryId != null) userPreferencesRepository.setFavorite(eateryId, false) + if (eateryId != null) { + viewModelScope.launch { + userRepository.removeFavoriteEatery(eateryId) + } + } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 4e4272cc..4a96c4db 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository +import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterData import com.cornellappdev.android.eatery.ui.components.general.updateFilters @@ -28,7 +29,8 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, - private val eateryRepository: EateryRepository + private val eateryRepository: EateryRepository, + private val userRepository: UserRepository ) : ViewModel() { private val _filtersFlow: MutableStateFlow> = MutableStateFlow(listOf()) @@ -54,14 +56,18 @@ class HomeViewModel @Inject constructor( */ val eateryFlow: StateFlow>> = combine( - eateryRepository.homeEateryFlow, + eateryRepository.eateryFlow, _filtersFlow, - userPreferencesRepository.favoritesFlow - ) { apiResponse, filters, favorites -> + userRepository.favoriteEateriesFlow + ) { apiResponse, filters, favoriteEateries -> when (apiResponse) { is EateryApiResponse.Error -> EateryApiResponse.Error is EateryApiResponse.Pending -> EateryApiResponse.Pending is EateryApiResponse.Success -> { + val eateries = apiResponse.data + val favoriteEateryIds = + eateries.filter { it.id != null } + .associate { it.id!! to (it.name in favoriteEateries) } EateryApiResponse.Success( apiResponse.data.filter { eatery -> Filter.passesSelectedFilters( @@ -69,7 +75,7 @@ class HomeViewModel @Inject constructor( selectedFilters = filters, filterData = FilterData( eatery, - favoriteEateryIds = favorites + favoriteEateryIds = favoriteEateryIds ) ) }.sortedBy { eatery -> @@ -87,15 +93,15 @@ class HomeViewModel @Inject constructor( */ val favoriteEateries = combine( - eateryRepository.homeEateryFlow, - userPreferencesRepository.favoritesFlow + eateryRepository.eateryFlow, + userRepository.favoriteEateriesFlow ) { apiResponse, favorites -> when (apiResponse) { is EateryApiResponse.Error -> listOf() is EateryApiResponse.Pending -> listOf() is EateryApiResponse.Success -> { apiResponse.data.filter { - favorites[it.id] == true + it.name in favorites } .sortedBy { it.name } .sortedBy { it.isClosed() } @@ -148,14 +154,16 @@ class HomeViewModel @Inject constructor( _filtersFlow.update { emptyList() } } - fun addFavorite(eateryId: Int?) { - if (eateryId != null) - userPreferencesRepository.setFavorite(eateryId, true) + fun addFavoriteEatery(eateryId: Int) { + viewModelScope.launch { + userRepository.addFavoriteEatery(eateryId) + } } - fun removeFavorite(eateryId: Int?) { - if (eateryId != null) - userPreferencesRepository.setFavorite(eateryId, false) + fun removeFavoriteEatery(eateryId: Int) { + viewModelScope.launch { + userRepository.removeFavoriteEatery(eateryId) + } } fun getNotificationFlowCompleted() = runBlocking { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 3fc72855..7a4c08b0 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -1,6 +1,5 @@ package com.cornellappdev.android.eatery.ui.viewmodels -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.AccountBalances @@ -8,21 +7,17 @@ import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.data.models.TransactionAccountType import com.cornellappdev.android.eatery.data.models.User import com.cornellappdev.android.eatery.data.models.toTransactionAccountType -import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await import java.time.LocalDateTime import java.time.format.DateTimeFormatter -import java.util.UUID import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, private val userRepository: UserRepository, ) : ViewModel() { @@ -69,11 +64,11 @@ class LoginViewModel @Inject constructor( val state = _state.asStateFlow() init { - getSavedLoginInfo() - } - - fun resetLogin() { - _state.value = State.Login() + viewModelScope.launch { + if (userRepository.isLoggedIn()) { + getFinancials() + } + } } fun updateAccountFilter(newAccountType: TransactionAccountType) { @@ -128,63 +123,50 @@ class LoginViewModel @Inject constructor( fun onLogoutPressed() { val newState = State.Login() _state.value = newState - viewModelScope.launch { - userRepository.logout() - userPreferencesRepository.setIsLoggedIn(false) - userPreferencesRepository.saveLoginInfo("", "") - } - } - - private fun getSavedLoginInfo() = viewModelScope.launch { - if (userPreferencesRepository.getIsLoggedIn()) { - val loginInfo = userPreferencesRepository.fetchLoginInfo() - getUser(loginInfo.first) - } + viewModelScope.launch { userRepository.logout() } } fun onLoginWebViewSuccess(sessionId: String) { - getUser(sessionId) + viewModelScope.launch { + linkGETAccount(sessionId) + getFinancials() + } } /** * Fetches user data given [sessionId] and updates the state and user preferences. */ - private fun getUser(sessionId: String) = viewModelScope.launch { - val currState = _state.value - if (userPreferencesRepository.getDeviceId() == null) { - userPreferencesRepository.setDeviceId(UUID.randomUUID()) - } + private suspend fun linkGETAccount(sessionId: String) { try { - val fcmToken = - com.google.firebase.messaging.FirebaseMessaging.getInstance().token.await() - val deviceId = userPreferencesRepository.getDeviceId()!! - Log.d("debug", "sessionId: $sessionId, deviceId: $deviceId, fcmToken: $fcmToken") - val user = userRepository.getUser(sessionId, deviceId, fcmToken) - if (currState is State.Login) { - userPreferencesRepository.saveLoginInfo(sessionId, currState.password) - userPreferencesRepository.setIsLoggedIn(true) - } - val newState = State.Account( - user = user, - query = "", - accountFilter = TransactionAccountType.BRBS - ) - _state.value = newState + userRepository.linkGETAccount(sessionId) } catch (e: Exception) { - // todo - error state + // todo error state val currState = _state.value if (currState is State.Login) { - val newState = State.Login( - netID = currState.netID, - password = currState.password, + val newState = currState.copy( failureMessage = e.stackTraceToString(), loading = false ) _state.value = newState } - userPreferencesRepository.saveLoginInfo("", "") - userPreferencesRepository.setIsLoggedIn(false) } } + + suspend fun getFinancials() { + val financials = userRepository.getFinancials() + val newState = State.Account( + // todo null states should be handled + user = User( + brbBalance = financials.accounts?.brbBalance?.balance, + cityBucksBalance = financials.accounts?.cityBucksBalance?.balance, + laundryBalance = financials.accounts?.laundryBalance?.balance, + transactions = financials.transactions?.transactions, +// mealSwipes = financials.accounts?. todo - mealswipes + ), + query = "", + accountFilter = TransactionAccountType.BRBS + ) + _state.value = newState + } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt index c2ccde96..7b3df307 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.repositories.EateryRepository -import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository +import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -19,24 +20,22 @@ import javax.inject.Inject */ @HiltViewModel class NearestViewModel @Inject constructor( - private val eateryRepository: EateryRepository, - private val userPreferencesRepository: UserPreferencesRepository + eateryRepository: EateryRepository, + private val userRepository: UserRepository ) : ViewModel() { /** * A flow emitting all the eateries the user has favorited. */ val favoriteEateries = combine( - eateryRepository.homeEateryFlow, - userPreferencesRepository.favoritesFlow - ) { apiResponse, favorites -> + eateryRepository.eateryFlow, + userRepository.favoriteEateriesFlow + ) { apiResponse, favoriteEateries -> when (apiResponse) { is EateryApiResponse.Error -> listOf() is EateryApiResponse.Pending -> listOf() is EateryApiResponse.Success -> { - apiResponse.data.filter { - favorites[it.id] == true - } + apiResponse.data.filter { it.name in favoriteEateries } .sortedBy { it.name } .sortedBy { it.isClosed() } } @@ -49,7 +48,7 @@ class NearestViewModel @Inject constructor( * Sorted (by descending priority): Open/Closed, Walk Time */ val nearestEateries: StateFlow> = - eateryRepository.homeEateryFlow.map { apiResponse -> + eateryRepository.eateryFlow.map { apiResponse -> when (apiResponse) { is EateryApiResponse.Error -> listOf() is EateryApiResponse.Pending -> listOf() @@ -64,6 +63,14 @@ class NearestViewModel @Inject constructor( * Changes the favorite status of the given eatery. */ fun setFavorite(eateryId: Int?, favorite: Boolean) { - if (eateryId != null) userPreferencesRepository.setFavorite(eateryId, favorite) + if (eateryId != null) { + viewModelScope.launch { + if (favorite) { + userRepository.addFavoriteEatery(eateryId) + } else { + userRepository.removeFavoriteEatery(eateryId) + } + } + } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/OnboardingViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/OnboardingViewModel.kt index 18b2457f..2c4468fe 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/OnboardingViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/OnboardingViewModel.kt @@ -9,7 +9,7 @@ import javax.inject.Inject @HiltViewModel class OnboardingViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, + private val userPreferencesRepository: UserPreferencesRepository ) : ViewModel() { fun updateOnboardingCompleted() = viewModelScope.launch { userPreferencesRepository.setHasOnboarded(hasOnboarded = true) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt index 1ed7002c..b6bbdbb0 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository +import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterData import com.cornellappdev.android.eatery.ui.components.general.updateFilters @@ -22,7 +23,8 @@ import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, - private val eateryRepository: EateryRepository + private val eateryRepository: EateryRepository, + private val userRepository: UserRepository ) : ViewModel() { private val _filtersFlow: MutableStateFlow> = MutableStateFlow(listOf()) @@ -55,9 +57,9 @@ class SearchViewModel @Inject constructor( * A flow of the eateries that should show up with the current query. */ val searchResultEateries = combine( - eateryRepository.homeEateryFlow, + eateryRepository.eateryFlow, filtersFlow, - userPreferencesRepository.favoritesFlow, + userRepository.favoriteEateriesFlow, _searchFlow ) { eateryApiResponse, filters, favorites, searchQuery -> when (eateryApiResponse) { @@ -65,13 +67,14 @@ class SearchViewModel @Inject constructor( is EateryApiResponse.Pending -> EateryApiResponse.Pending is EateryApiResponse.Success -> { EateryApiResponse.Success( - eateryApiResponse.data.sortedBy { it.isClosed() }.filter { + eateryApiResponse.data.sortedBy { it.isClosed() }.filter { eatery -> Filter.passesSelectedFilters( searchScreenFilters, filters, FilterData( - eatery = it, - favoriteEateryIds = favorites + eatery = eatery, + favoriteEateryIds = eateryApiResponse.data.filter { it.id != null } + .associate { it.id!! to (it.name in favorites) } ) - ) && it.passesSearch(searchQuery) + ) && eatery.passesSearch(searchQuery) }) } } @@ -88,15 +91,15 @@ class SearchViewModel @Inject constructor( */ val favoriteEateries = combine( - eateryRepository.homeEateryFlow, - userPreferencesRepository.favoritesFlow + eateryRepository.eateryFlow, + userRepository.favoriteEateriesFlow ) { apiResponse, favorites -> when (apiResponse) { is EateryApiResponse.Error -> listOf() is EateryApiResponse.Pending -> listOf() is EateryApiResponse.Success -> { apiResponse.data.filter { - favorites[it.id] == true + it.name in favorites } } } @@ -132,13 +135,19 @@ class SearchViewModel @Inject constructor( } fun addFavorite(eateryId: Int?) { - if (eateryId != null) - userPreferencesRepository.setFavorite(eateryId, true) + if (eateryId != null) { + viewModelScope.launch { + userRepository.addFavoriteEatery(eateryId) + } + } } fun removeFavorite(eateryId: Int?) { - if (eateryId != null) - userPreferencesRepository.setFavorite(eateryId, false) + if (eateryId != null) { + viewModelScope.launch { + userRepository.removeFavoriteEatery(eateryId) + } + } } fun addRecentSearch(eateryId: Int?) = viewModelScope.launch { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt index 1ce69b16..1a4398df 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/UpcomingViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.EateryStatus import com.cornellappdev.android.eatery.data.repositories.EateryRepository -import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository +import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterData import com.cornellappdev.android.eatery.ui.components.general.MealFilter @@ -44,8 +44,8 @@ data class EateriesSection( @HiltViewModel class UpcomingViewModel @Inject constructor( - userPreferencesRepository: UserPreferencesRepository, - private val eateryRepository: EateryRepository + private val eateryRepository: EateryRepository, + userRepository: UserRepository ) : ViewModel() { private val mealFilterFlow = MutableStateFlow(nextMeal() ?: MealFilter.LATE_DINNER) @@ -65,22 +65,22 @@ class UpcomingViewModel @Inject constructor( val viewStateFlow: StateFlow = combine( eateryRepository.eateryFlow, selectedFiltersFlow, - userPreferencesRepository.favoriteItemsFlow, + userRepository.favoriteItemsFlow, mealFilterFlow, selectedDayFlow - ) { eateryApiResponse, filters, favoriteItemsMap, mealFilter, selectedDayOffset -> + ) { eateryApiResponse, filters, favoriteItems, mealFilter, selectedDayOffset -> val viewingDate = LocalDate.now().plusDays(selectedDayOffset.toLong()) fun Eatery.toMenuCardViewState(): MenuCardViewState? { val currentEvent = events?.find { - it.description in mealFilter.text && - it.startTime?.toLocalDate() == viewingDate + it.type in mealFilter.text && + it.startTimestamp?.toLocalDate() == viewingDate } ?: return null return MenuCardViewState( - eateryHours = currentEvent.startTime?.let { startTime -> - currentEvent.endTime?.let { endTime -> + eateryHours = currentEvent.startTimestamp?.let { startTime -> + currentEvent.endTimestamp?.let { endTime -> val timePattern = "hh:mm a" EateryHours( startTime = startTime.format( @@ -95,11 +95,11 @@ class UpcomingViewModel @Inject constructor( eateryId = id ?: return null, menu = currentEvent.menu?.map { menu -> MenuCategoryViewState( - menu.category ?: "", + menu.name ?: "", menu.items?.map { menuItem -> MenuItemViewState( item = menuItem, - isFavorite = favoriteItemsMap[menuItem.name] == true + isFavorite = menuItem.name in favoriteItems ) } ?: emptyList() ) @@ -132,7 +132,6 @@ class UpcomingViewModel @Inject constructor( ) } - is EateryApiResponse.Success -> { val data = eateryApiResponse.data.filter { eatery -> Filter.passesSelectedFilters(upcomingMenuFilters, filters, FilterData(eatery)) @@ -147,7 +146,8 @@ class UpcomingViewModel @Inject constructor( }?.takeIf { it.isNotEmpty() } menuCards?.let { EateriesSection( - header = location, + header = location.lowercase() + .replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase() else c.toString() }, menuCards = it ) } diff --git a/app/src/main/proto/user_prefs.proto b/app/src/main/proto/user_prefs.proto index 4ac3736d..70c9737e 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -11,23 +11,21 @@ message UserPreferences { map favorites = 3; bool isLoggedIn = 4; + string sessionId = 5; + repeated int32 recentSearches = 6; - string username = 5; + bool analyticsDisabled = 7; - // Must be encrypted / decrypted. - string password = 6; + Date lastShowedRatingPopup = 8; - repeated int32 recentSearches = 7; + int32 minDaysBetweenRatingShow = 9; - bool analyticsDisabled = 8; + map itemFavorites = 10; - Date lastShowedRatingPopup = 9; - - int32 minDaysBetweenRatingShow = 10; - - map itemFavorites = 11; - - string deviceId = 12; + string deviceId = 11; + string accessToken = 12; + string refreshToken = 13; + int32 pin = 14; // repeated int32 recentSearches = 2; // string username = 3; From 1aff0937fa316843495585a7647a8d5831897e86 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 25 Feb 2026 22:22:56 -0500 Subject: [PATCH 054/126] Remove no longer used buildConfigFields --- app/build.gradle | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7e357385..084f7a4a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,9 +31,7 @@ android { useSupportLibrary true } - buildConfigField("String", "GET_BACKEND_URL", secretsProperties['GET_BACKEND_URL']) buildConfigField("String", "SESSIONID_WEBVIEW_URL", secretsProperties['SESSIONID_WEBVIEW_URL']) - buildConfigField("String", "CORNELL_INSTITUTION_ID", secretsProperties['CORNELL_INSTITUTION_ID']) } buildTypes { From 306a021f86f721b8df28972341a4ca8455b3d88b Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 1 Mar 2026 23:21:36 -0500 Subject: [PATCH 055/126] Fix financials and login routes and use flows for account page --- .../android/eatery/data/NetworkingApi.kt | 5 +- .../android/eatery/data/models/User.kt | 12 ++-- .../repositories/UserPreferencesRepository.kt | 1 + .../data/repositories/UserRepository.kt | 30 ++++++---- .../eatery/ui/components/login/AccountPage.kt | 55 +++++++++---------- .../eatery/ui/screens/ProfileScreen.kt | 15 +++-- .../eatery/ui/viewmodels/LoginViewModel.kt | 39 ++++++------- 7 files changed, 80 insertions(+), 77 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index 382ad960..825c4944 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -101,9 +101,10 @@ interface NetworkApi { @Body loginPIN: LoginPIN ): SessionID - @GET("/financials") + @POST("/financials") suspend fun getFinancials( - @Header("Authorization") accessToken: String + @Header("Authorization") accessToken: String, + @Body sessionId: SessionID ): Financials @GET("/user/favorites/matches") diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index a6595796..5b996a52 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -67,13 +67,13 @@ data class User( @JsonClass(generateAdapter = true) data class LoginRequest( - @Json(name = "pin") val pin: Int, + @Json(name = "pin") val pin: String, @Json(name = "sessionId") val sessionId: String, ) @JsonClass(generateAdapter = true) data class LoginPIN( - @Json(name = "pin") val pin: Int + @Json(name = "pin") val pin: String ) @JsonClass(generateAdapter = true) @@ -84,7 +84,7 @@ data class SessionID( @JsonClass(generateAdapter = true) data class Financials( @Json(name = "accounts") val accounts: Accounts? = null, - @Json(name = "transactions") val transactions: Transactions? = null + @Json(name = "transactions") val transactions: List? = null ) @@ -101,14 +101,10 @@ data class Account( @Json(name = "balance") val balance: Double = 0.0 ) -@JsonClass(generateAdapter = true) -data class Transactions( - @Json(name = "transactions") val transactions: List = emptyList() -) - @JsonClass(generateAdapter = true) data class Transaction( @Json(name = "amount") val amount: Double = 0.0, + val tenderId: Int? = 0, @Json(name = "accountName") val accountType: AccountType = AccountType.OTHER, @Json(name = "date") val date: String = "", @Json(name = "location") val location: String = "", diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 93a5a600..1a5e3fdb 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -106,6 +106,7 @@ class UserPreferencesRepository @Inject constructor( setPref { setPin(pin) } } + suspend fun getSessionId(): String = userPreferencesFlow.first().sessionId suspend fun setSessionId(sessionId: String) { setPref { setSessionId(sessionId) } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index ff08d0a8..96365a6d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -9,6 +9,7 @@ import com.cornellappdev.android.eatery.data.models.LoginPIN import com.cornellappdev.android.eatery.data.models.LoginRequest import com.cornellappdev.android.eatery.data.models.RefreshRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody +import com.cornellappdev.android.eatery.data.models.SessionID import com.cornellappdev.android.eatery.data.models.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -145,7 +146,7 @@ class UserRepository @Inject constructor( tryRequest { networkApi.authorizeUser( accessToken = getAccessToken(), - loginRequest = LoginRequest(pin, sessionId) + loginRequest = LoginRequest(pin.toString(), sessionId) ) } } @@ -154,13 +155,16 @@ class UserRepository @Inject constructor( var financials: Financials try { financials = networkApi.getFinancials( - accessToken = getAccessToken() + accessToken = getAccessToken(), + sessionId = SessionID(userPreferencesRepository.getSessionId()) ) } catch (_: Exception) { - val pin = - userPreferencesRepository.getPin() + val pin = userPreferencesRepository.getPin() refreshLogin(pin = pin) - financials = networkApi.getFinancials(accessToken = getAccessToken()) + financials = networkApi.getFinancials( + accessToken = getAccessToken(), + sessionId = SessionID(userPreferencesRepository.getSessionId()) + ) } financials } @@ -168,14 +172,18 @@ class UserRepository @Inject constructor( suspend fun isLoggedIn(): Boolean = userPreferencesRepository.getIsLoggedIn() /** - * Refreshes GET sessionID and returns it. + * Refreshes GET sessionID. */ suspend fun refreshLogin(pin: Int) = tryRequest { val newSessionId = networkApi.refreshAuthorizedUser( accessToken = getAccessToken(), - loginPIN = LoginPIN(pin) - ).toString() - userPreferencesRepository.setSessionId(newSessionId) + loginPIN = LoginPIN(pin.toString()) + ).sessionId + if (newSessionId == null) { + // todo - handle + } else { + userPreferencesRepository.setSessionId(newSessionId) + } } suspend fun logout() { @@ -197,6 +205,7 @@ class UserRepository @Inject constructor( refreshTokens() return request() } catch (e: Exception) { + // todo - pass in handler throw e } } @@ -232,6 +241,7 @@ class UserRepository @Inject constructor( * Gets access token with Bearer prefix assuming device has been registered */ private suspend fun getAccessToken(): String = - "Bearer ${userPreferencesRepository.getAccessToken()!!}" + prependBearer(userPreferencesRepository.getAccessToken()!!) + private fun prependBearer(str: String) = "Bearer $str" } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 2b443f32..40ba71df 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -82,10 +82,15 @@ fun AccountPage( accountFilter: TransactionAccountType, accountTypeBalance: AccountBalances, onSettingsClicked: () -> Unit, - getTransactionsOfType: (TransactionAccountType, String) -> List, + filteredTransactions: List, + onQueryChanged: (String) -> Unit, updateAccountFilter: (TransactionAccountType) -> Unit ) { var filterText by remember { mutableStateOf("") } + val onFilterTextChanged = { newText: String -> + filterText = newText + onQueryChanged(newText) + } val modalBottomSheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, @@ -132,20 +137,20 @@ fun AccountPage( accountFilter, showBottomSheet = modalBottomSheetState::show, filterText, - setFilterText = { filterText = it }, - getTransactionsOfType, + setFilterText = onFilterTextChanged, + filteredTransactions, setSheetContent = { sheetContent = it }, ) } } -@Composable @OptIn( ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, ExperimentalAnimationApi::class ) +@Composable private fun AccountPageContent( onSettingsClicked: () -> Unit, accountTypeBalance: AccountBalances, @@ -153,7 +158,7 @@ private fun AccountPageContent( showBottomSheet: suspend () -> Unit, filterText: String, setFilterText: (String) -> Unit, - getTransactionsOfType: (TransactionAccountType, String) -> List, + filteredTransactions: List, setSheetContent: (BottomSheetContent) -> Unit ) { val innerListState = rememberLazyListState() @@ -237,12 +242,7 @@ private fun AccountPageContent( setFilterText ) } - items( - getTransactionsOfType( - accountFilter, - filterText - ) - ) { + items(filteredTransactions) { TransactionRow( transaction = it, isMealSwipes = accountFilter == TransactionAccountType.MEAL_SWIPES @@ -267,22 +267,20 @@ private fun AccountPagePreview() = EateryPreview { showBottomSheet = {}, filterText = "", setFilterText = {}, - getTransactionsOfType = { _, _ -> - listOf( - Transaction( - date = "2023-10-01T12:30:00.000Z", - location = "Cafe Jennie", - amount = 5.25, - transactionType = TransactionType.SPEND - ), - Transaction( - date = "2023-10-02T14:00:00.000Z", - location = "Morrison Dining", - amount = 15.00, - transactionType = TransactionType.DEPOSIT - ) + filteredTransactions = listOf( + Transaction( + date = "2023-10-01T12:30:00.000Z", + location = "Cafe Jennie", + amount = 5.25, + transactionType = TransactionType.SPEND + ), + Transaction( + date = "2023-10-02T14:00:00.000Z", + location = "Morrison Dining", + amount = 15.00, + transactionType = TransactionType.DEPOSIT ) - }, + ), setSheetContent = {} ) } @@ -454,7 +452,7 @@ private fun AccountPageHeader( @Composable private fun TransactionRow(transaction: Transaction, isMealSwipes: Boolean) { - val dateText = FormatDate(transaction.date) + val dateText = formatDate(transaction.date) Row( modifier = Modifier .height(64.dp) @@ -509,8 +507,7 @@ private fun TransactionRow(transaction: Transaction, isMealSwipes: Boolean) { ) } -@Composable -private fun FormatDate(dateString: String): String { +private fun formatDate(dateString: String): String { val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") val outputFormatter = DateTimeFormatter.ofPattern("h:mm a · EEEE, MMMM d") diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 53e4336a..8e65ae46 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -23,6 +23,8 @@ fun ProfileScreen( onBackClick: () -> Unit ) { val state = loginViewModel.state.collectAsState().value + val filteredTransactions = + loginViewModel.filteredTransactionsFlow.collectAsState(initial = emptyList()).value ProfileScreenContent( isLoginState = state is LoginViewModel.State.Login, accountTypeBalance = state.getBalances(), @@ -34,8 +36,8 @@ fun ProfileScreen( onModalHidden = loginViewModel::onLoginExited, onSettingsClicked = onSettingsClicked, accountFilter = if (state is LoginViewModel.State.Account) state.accountFilter else TransactionAccountType.BRBS, - - getTransactionsOfType = loginViewModel::getFilteredTransactions, + filteredTransactions = filteredTransactions, + onQueryChanged = loginViewModel::setQuery, updateAccountFilter = loginViewModel::updateAccountFilter ) } @@ -52,7 +54,8 @@ private fun ProfileScreenContent( onModalHidden: () -> Unit, accountFilter: TransactionAccountType, onSettingsClicked: () -> Unit, - getTransactionsOfType: (TransactionAccountType, String) -> List, + filteredTransactions: List, + onQueryChanged: (String) -> Unit, updateAccountFilter: (TransactionAccountType) -> Unit ) { if (isLoginState) { @@ -69,7 +72,8 @@ private fun ProfileScreenContent( accountFilter = accountFilter, accountTypeBalance = accountTypeBalance, onSettingsClicked = onSettingsClicked, - getTransactionsOfType = getTransactionsOfType, + filteredTransactions = filteredTransactions, + onQueryChanged = onQueryChanged, updateAccountFilter = updateAccountFilter ) } @@ -100,7 +104,8 @@ private fun ProfileLoginScreenPreview() = EateryPreview { onModalHidden = {}, accountFilter = TransactionAccountType.BRBS, onSettingsClicked = {}, - getTransactionsOfType = { _, _ -> emptyList() }, + filteredTransactions = emptyList(), + onQueryChanged = {}, updateAccountFilter = {}, ) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 7a4c08b0..78b4b42a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -9,8 +9,10 @@ import com.cornellappdev.android.eatery.data.models.User import com.cornellappdev.android.eatery.data.models.toTransactionAccountType import com.cornellappdev.android.eatery.data.repositories.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -60,7 +62,6 @@ class LoginViewModel @Inject constructor( } ?: State.Login() ) - // Convert the state to a flow that can be updated by screens that use the LoginViewModel val state = _state.asStateFlow() init { @@ -71,29 +72,23 @@ class LoginViewModel @Inject constructor( } } - fun updateAccountFilter(newAccountType: TransactionAccountType) { - val currState = _state.value - if (currState !is State.Account) return + private val _queryFlow = MutableStateFlow("") - // currState is a Login state (expected). - val newState = State.Account( - currState.user, - "", - newAccountType - ) + fun setQuery(query: String) { + _queryFlow.value = query + } - // Send the new netID Login state down. - _state.value = newState + private val _accountTypeFilterFlow = MutableStateFlow(TransactionAccountType.BRBS) + + fun updateAccountFilter(newAccountType: TransactionAccountType) { + _accountTypeFilterFlow.value = newAccountType } - fun getFilteredTransactions( - accountType: TransactionAccountType, - query: String - ): List { - val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") - userRepository.loadedUser.value?.let { - if (_state.value !is State.Account) return emptyList() - return it.transactions?.filter { transaction -> + val filteredTransactionsFlow: Flow> = + combine(_state, _queryFlow, _accountTypeFilterFlow) { state, query, accountType -> + if (state !is State.Account) return@combine emptyList() + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") + state.user.transactions?.filter { transaction -> val matchesAccountType = transaction.accountType.toTransactionAccountType() == accountType val pastThirtyDays = LocalDateTime.parse( @@ -104,8 +99,6 @@ class LoginViewModel @Inject constructor( matchesAccountType && pastThirtyDays && matchesQuery } ?: emptyList() } - return emptyList() - } fun onLoginPressed() = updateLoginLoadingState(true) @@ -160,7 +153,7 @@ class LoginViewModel @Inject constructor( brbBalance = financials.accounts?.brbBalance?.balance, cityBucksBalance = financials.accounts?.cityBucksBalance?.balance, laundryBalance = financials.accounts?.laundryBalance?.balance, - transactions = financials.transactions?.transactions, + transactions = financials.transactions, // mealSwipes = financials.accounts?. todo - mealswipes ), query = "", From 810228ce93821906f7db2c8b5013bc1590bd5e99 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 2 Mar 2026 00:54:31 -0500 Subject: [PATCH 056/126] Fix time zone issue with transaction times --- .../android/eatery/data/MoshiAdapters.kt | 16 ------------- .../android/eatery/data/models/User.kt | 2 +- .../eatery/ui/components/login/AccountPage.kt | 23 +++++++++++++------ 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt index 0bda78e6..c959721a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt @@ -64,22 +64,6 @@ class ReportAdapter { } } -class TransactionDateAdapter { - @FromJson - fun fromJson(date: String): Date { - try { - val simpleDate = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZ", Locale.US).parse(date) - if (simpleDate != null) { - return simpleDate - } - } catch (e: ParseException) { - e.printStackTrace() - } - return Date(0) - } -} - class DateTimeAdapter { @ToJson fun toJson(dateTime: LocalDateTime): String { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index 5b996a52..2bd1ff93 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -104,7 +104,7 @@ data class Account( @JsonClass(generateAdapter = true) data class Transaction( @Json(name = "amount") val amount: Double = 0.0, - val tenderId: Int? = 0, + val tenderId: String? = "", @Json(name = "accountName") val accountType: AccountType = AccountType.OTHER, @Json(name = "date") val date: String = "", @Json(name = "location") val location: String = "", diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 40ba71df..32a909de 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -69,7 +69,7 @@ import com.cornellappdev.android.eatery.ui.theme.Green import com.cornellappdev.android.eatery.ui.theme.Red import com.cornellappdev.android.eatery.util.EateryPreview import kotlinx.coroutines.launch -import java.time.LocalDateTime +import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import kotlin.math.abs @@ -508,12 +508,21 @@ private fun TransactionRow(transaction: Transaction, isMealSwipes: Boolean) { } private fun formatDate(dateString: String): String { - val inputFormatter = - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") - val outputFormatter = DateTimeFormatter.ofPattern("h:mm a · EEEE, MMMM d") - val dateTime = LocalDateTime.parse(dateString, inputFormatter) - val dateText = outputFormatter.format(dateTime) - return dateText ?: "" + return try { + // Parse timezone-aware string like "2026-03-02T01:56:45.000+0000" + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") + val zonedDateTime = ZonedDateTime.parse(dateString, inputFormatter) + + // Convert to system's local timezone + val localZonedDateTime = zonedDateTime.withZoneSameInstant(java.time.ZoneId.systemDefault()) + val localDateTime = localZonedDateTime.toLocalDateTime() + + val outputFormatter = DateTimeFormatter.ofPattern("h:mm a · EEEE, MMMM d") + outputFormatter.format(localDateTime) + } catch (e: Exception) { + e.printStackTrace() + "" + } } private fun Double.epsilonEqual(other: Double): Boolean { From b95817e59118fab0af45fbfe1efcfe8ff1370c03 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 2 Mar 2026 01:03:57 -0500 Subject: [PATCH 057/126] Keep user logged in --- .../data/repositories/UserPreferencesRepository.kt | 9 +++++++-- .../android/eatery/data/repositories/UserRepository.kt | 2 ++ .../android/eatery/ui/viewmodels/LoginViewModel.kt | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 1a5e3fdb..fab83bc9 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -96,9 +96,14 @@ class UserPreferencesRepository @Inject constructor( setPref { setRefreshToken(refreshToken) } } - suspend fun getIsLoggedIn(): Boolean = userPreferencesFlow.firstOrNull()?.isLoggedIn ?: false + suspend fun getIsLoggedIn(): Boolean { + val flow = userPreferencesFlow.firstOrNull() + return flow?.isLoggedIn ?: false + } - suspend fun setIsLoggedIn(loggedIn: Boolean) = setPref { setIsLoggedIn(loggedIn) } + suspend fun setIsLoggedIn(loggedIn: Boolean) = setPref { + setIsLoggedIn(loggedIn) + } suspend fun getPin(): Int = userPreferencesFlow.first().pin diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 96365a6d..6a7d348b 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -169,6 +169,8 @@ class UserRepository @Inject constructor( financials } + suspend fun setIsLoggedIn(isLoggedIn: Boolean) = + userPreferencesRepository.setIsLoggedIn(isLoggedIn) suspend fun isLoggedIn(): Boolean = userPreferencesRepository.getIsLoggedIn() /** diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 78b4b42a..fa0362cc 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -132,6 +132,7 @@ class LoginViewModel @Inject constructor( private suspend fun linkGETAccount(sessionId: String) { try { userRepository.linkGETAccount(sessionId) + userRepository.setIsLoggedIn(true) } catch (e: Exception) { // todo error state val currState = _state.value From 00eb765c1a5251c230db7be3ec245bb3072a4e35 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 2 Mar 2026 12:38:31 -0500 Subject: [PATCH 058/126] Fix favoriting routes --- .../android/eatery/data/NetworkingApi.kt | 14 +++++++------- .../android/eatery/data/models/Eatery.kt | 4 ++-- .../models/{ApiModels.kt => GetReportModels.kt} | 1 - .../android/eatery/data/models/User.kt | 2 +- .../eatery/data/repositories/UserRepository.kt | 1 + .../eatery/ui/screens/EateryDetailScreen.kt | 12 +++++++----- 6 files changed, 18 insertions(+), 16 deletions(-) rename app/src/main/java/com/cornellappdev/android/eatery/data/models/{ApiModels.kt => GetReportModels.kt} (95%) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index 825c4944..187bc2a2 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -53,37 +53,37 @@ interface NetworkApi { * E.g., Authorization: Bearer a97syd9a77asydan9s * */ - @POST("/user/fcm-token") + @POST("/users/fcm-token") suspend fun enableNotifications( @Header("Authorization") accessToken: String, @Body token: FcmToken ) - @DELETE("/user/fcm-token") + @DELETE("/users/fcm-token") suspend fun disableNotifications( @Header("Authorization") accessToken: String, @Body token: FcmToken ) - @POST("/user/favorites/items") + @POST("/users/favorites/items") suspend fun addFavoriteItem( @Header("Authorization") accessToken: String, @Body item: FavoriteItem ) - @DELETE("/user/favorites/items") + @DELETE("/users/favorites/items") suspend fun deleteFavoriteItem( @Header("Authorization") accessToken: String, @Body item: FavoriteItem ) - @POST("/user/favorites/eateries") + @POST("/users/favorites/eateries") suspend fun addFavoriteEatery( @Header("Authorization") accessToken: String, @Body eatery: FavoriteEatery ) - @DELETE("/user/favorites/eateries") + @DELETE("/users/favorites/eateries") suspend fun deleteFavoriteEatery( @Header("Authorization") accessToken: String, @Body eatery: FavoriteEatery @@ -107,7 +107,7 @@ interface NetworkApi { @Body sessionId: SessionID ): Financials - @GET("/user/favorites/matches") + @GET("/users/favorites/matches") suspend fun getFavoriteMatches( @Header("Authorization") accessToken: String, ): FavoritesResponse diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt index 6d798727..0e4bba72 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color import com.cornellappdev.android.eatery.ui.components.general.MealFilter import com.cornellappdev.android.eatery.util.Constants.AVERAGE_WALK_SPEED import com.cornellappdev.android.eatery.util.LocationHandler +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,8 +23,7 @@ import java.util.Date @JsonClass(generateAdapter = true) data class Eatery( - val id: Int? = null, - val cornellId: Int? = null, + @Json(name = "cornellId") val id: Int? = null, val announcements: List? = null, val name: String? = null, val shortName: String? = null, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/ApiModels.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/GetReportModels.kt similarity index 95% rename from app/src/main/java/com/cornellappdev/android/eatery/data/models/ApiModels.kt rename to app/src/main/java/com/cornellappdev/android/eatery/data/models/GetReportModels.kt index 03a29e04..a0a26a65 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/ApiModels.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/GetReportModels.kt @@ -3,7 +3,6 @@ package com.cornellappdev.android.eatery.data.models import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -// todo - update these @JsonClass(generateAdapter = true) data class GetApiResponse( @Json(name = "response") val response: T? = null, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index 2bd1ff93..9b142766 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -34,7 +34,7 @@ data class FavoriteItem( @JsonClass(generateAdapter = true) data class FavoriteEatery( - @Json(name = "eateryId") val eateryId: Int + @Json(name = "cornellId") val eateryId: Int ) @JsonClass(generateAdapter = true) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 6a7d348b..ee35c8f3 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -171,6 +171,7 @@ class UserRepository @Inject constructor( suspend fun setIsLoggedIn(isLoggedIn: Boolean) = userPreferencesRepository.setIsLoggedIn(isLoggedIn) + suspend fun isLoggedIn(): Boolean = userPreferencesRepository.getIsLoggedIn() /** diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt index 936f660c..b5a86cd8 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt @@ -234,7 +234,8 @@ fun EateryDetailScreen( BottomSheetContent.REPORT -> { eatery.id?.let { - ReportBottomSheet(issue = issue, + ReportBottomSheet( + issue = issue, eateryid = it, sendReport = { issue, report, eateryid -> eateryDetailViewModel.sendReport( @@ -580,9 +581,9 @@ fun EateryDetailScreen( Text( modifier = Modifier.padding(top = 2.dp), text = - if (openUntil == null) "Closed" - else if (eatery.isClosingSoon()) "Closing at $openUntil" - else ("Open until $openUntil"), + if (openUntil == null) "Closed" + else if (eatery.isClosingSoon()) "Closing at $openUntil" + else ("Open until $openUntil"), style = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 16.sp @@ -675,7 +676,8 @@ fun EateryDetailScreen( } }) item { - SearchBar(searchText = filterText, + SearchBar( + searchText = filterText, onSearchTextChange = { eateryDetailViewModel.setSearchQuery( it From 06c30219384c2e4f4d889b2ba60a2c563b49e4fb Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 2 Mar 2026 14:04:55 -0500 Subject: [PATCH 059/126] Complete favoriting logic --- .../android/eatery/MainActivity.kt | 3 +- .../android/eatery/data/NetworkingApi.kt | 9 +++--- .../android/eatery/data/models/User.kt | 9 ++---- .../data/repositories/UserRepository.kt | 20 ++++++++++--- .../eatery/ui/screens/FavoritesScreen.kt | 8 +++-- .../android/eatery/ui/screens/HomeScreen.kt | 6 ++-- .../eatery/ui/screens/NearestScreen.kt | 6 ++-- .../android/eatery/ui/screens/SearchScreen.kt | 26 ++++++++++++----- .../ui/viewmodels/EateryDetailViewModel.kt | 5 ++-- .../ui/viewmodels/FavoritesViewModel.kt | 29 ++++++++++--------- .../eatery/ui/viewmodels/HomeViewModel.kt | 8 ++--- .../eatery/ui/viewmodels/NearestViewModel.kt | 14 ++++----- .../eatery/ui/viewmodels/SearchViewModel.kt | 16 ++++------ 13 files changed, 89 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 7262f2e6..4b453139 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -46,8 +46,7 @@ class MainActivity : ComponentActivity() { lifecycle.addObserver(dataRefresher) runBlocking { configureTokens() - // todo - uncomment when backend finishes favorites -// userRepository.updateFavorites() + userRepository.updateFavorites() } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index 187bc2a2..a5c61c5d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -5,18 +5,19 @@ import com.cornellappdev.android.eatery.data.models.DeviceId import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.FavoriteEatery import com.cornellappdev.android.eatery.data.models.FavoriteItem -import com.cornellappdev.android.eatery.data.models.FavoritesResponse import com.cornellappdev.android.eatery.data.models.FcmToken import com.cornellappdev.android.eatery.data.models.Financials import com.cornellappdev.android.eatery.data.models.GetApiResponse import com.cornellappdev.android.eatery.data.models.LoginPIN import com.cornellappdev.android.eatery.data.models.LoginRequest +import com.cornellappdev.android.eatery.data.models.Match import com.cornellappdev.android.eatery.data.models.RefreshRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody import com.cornellappdev.android.eatery.data.models.SessionID import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path @@ -71,7 +72,7 @@ interface NetworkApi { @Body item: FavoriteItem ) - @DELETE("/users/favorites/items") + @HTTP(method = "DELETE", path = "/users/favorites/items", hasBody = true) suspend fun deleteFavoriteItem( @Header("Authorization") accessToken: String, @Body item: FavoriteItem @@ -83,7 +84,7 @@ interface NetworkApi { @Body eatery: FavoriteEatery ) - @DELETE("/users/favorites/eateries") + @HTTP(method = "DELETE", path = "/users/favorites/eateries", hasBody = true) suspend fun deleteFavoriteEatery( @Header("Authorization") accessToken: String, @Body eatery: FavoriteEatery @@ -110,5 +111,5 @@ interface NetworkApi { @GET("/users/favorites/matches") suspend fun getFavoriteMatches( @Header("Authorization") accessToken: String, - ): FavoritesResponse + ): List } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index 9b142766..462e03a7 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -37,11 +37,6 @@ data class FavoriteEatery( @Json(name = "cornellId") val eateryId: Int ) -@JsonClass(generateAdapter = true) -data class FavoritesResponse( - @Json(name = "matches") val matches: List? = null -) - @JsonClass(generateAdapter = true) data class Match( @Json(name = "eateryName") val eateryName: String? = null, @@ -50,8 +45,8 @@ data class Match( @JsonClass(generateAdapter = true) data class Item( - @Json(name = "name") val name: String? = null, - @Json(name = "events") val events: List? = null + val name: String? = null, + val events: List? = null ) @JsonClass(generateAdapter = true) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index ee35c8f3..33fb931b 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -14,6 +14,7 @@ import com.cornellappdev.android.eatery.data.models.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -85,10 +86,9 @@ class UserRepository @Inject constructor( suspend fun updateFavorites() { val accessPhrase = getAccessToken() - val favoritesResponse = tryRequest { + val matches = tryRequest { networkApi.getFavoriteMatches(accessToken = accessPhrase) } - val matches = favoritesResponse.matches ?: return _favoritesEateriesFlow.value = matches.mapNotNull { it.eateryName } _favoriteItemsFlow.value = run { val items: MutableList = mutableListOf() @@ -116,6 +116,9 @@ class UserRepository @Inject constructor( accessToken = getAccessToken(), item = FavoriteItem(item = name) ) + _favoriteItemsFlow.update { currentItems -> + if (name !in currentItems) currentItems + name else currentItems + } } suspend fun removeFavoriteItem(name: String) = tryRequest { @@ -123,20 +126,29 @@ class UserRepository @Inject constructor( accessToken = getAccessToken(), item = FavoriteItem(name) ) + _favoriteItemsFlow.update { currentItems -> + currentItems.filter { it != name } + } } - suspend fun addFavoriteEatery(id: Int) = tryRequest { + suspend fun addFavoriteEatery(id: Int, eateryName: String) = tryRequest { networkApi.addFavoriteEatery( accessToken = getAccessToken(), eatery = FavoriteEatery(id), ) + _favoritesEateriesFlow.update { currentEateries -> + if (eateryName !in currentEateries) currentEateries + eateryName else currentEateries + } } - suspend fun removeFavoriteEatery(id: Int) = tryRequest { + suspend fun removeFavoriteEatery(id: Int, eateryName: String) = tryRequest { networkApi.deleteFavoriteEatery( accessToken = getAccessToken(), eatery = FavoriteEatery(id) ) + _favoritesEateriesFlow.update { currentEateries -> + currentEateries.filter { it != eateryName } + } } suspend fun linkGETAccount(sessionId: String) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt index 61393cf4..f4fc7753 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt @@ -153,7 +153,7 @@ private fun ColumnScope.MainScrollableContent( onEateryClick: (eatery: Eatery) -> Unit, toggleEateryFilter: (filter: Filter.FromEateryFilter) -> Unit, toggleItemFilter: (filter: Filter) -> Unit, - removeFavorite: (eateryId: Int?) -> Unit, + removeFavorite: (eateryId: Int, eateryName: String) -> Unit, removeFavoriteMenuItem: (menuItem: String) -> Unit, ) { LazyColumn( @@ -204,7 +204,11 @@ private fun ColumnScope.MainScrollableContent( modifier = Modifier.animateItemPlacement(), onFavoriteClick = { if (!it) { - removeFavorite(eatery.id) + eatery.id?.let { id -> + eatery.name?.let { name -> + removeFavorite(id, name) + } + } } }) { onEateryClick(it) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 7a7b734d..6fb43cbd 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -226,11 +226,11 @@ fun HomeScreen( nearestEateries = nearestEateries, selectedFilters = filters, onFavoriteClick = { eatery, favorite -> - if (eatery.id != null) { + if (eatery.id != null && eatery.name != null) { if (favorite) { - homeViewModel.addFavoriteEatery(eatery.id) + homeViewModel.addFavoriteEatery(eatery.id, eatery.name) } else { - homeViewModel.removeFavoriteEatery(eatery.id) + homeViewModel.removeFavoriteEatery(eatery.id, eatery.name) } } }, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NearestScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NearestScreen.kt index 1cdc28c7..d3dfae3a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NearestScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NearestScreen.kt @@ -43,7 +43,7 @@ fun NearestScreen( nearestViewModel: NearestViewModel = hiltViewModel(), onEateryClick: (eatery: Eatery) -> Unit ) { - val shimmer = rememberShimmer(ShimmerBounds.View) + rememberShimmer(ShimmerBounds.View) val nearestEateries = nearestViewModel.nearestEateries.collectAsState().value val favorites = nearestViewModel.favoriteEateries.collectAsState().value @@ -107,7 +107,9 @@ fun NearestScreen( eatery = eatery, isFavorite = favorites.contains(eatery), onFavoriteClick = { - nearestViewModel.setFavorite(eatery.id, it) + if (eatery.id != null && eatery.name != null) { + nearestViewModel.setFavorite(eatery.id, eatery.name, it) + } }) { onEateryClick(it) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt index fcf8d2bd..8fe9026c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt @@ -260,10 +260,18 @@ fun SearchScreen( favoriteEatery.id == eatery.id }, onFavoriteClick = { - if (it) { - searchViewModel.addFavorite(eatery.id) - } else { - searchViewModel.removeFavorite(eatery.id) + if (eatery.id != null && eatery.name != null) { + if (it) { + searchViewModel.addFavorite( + eatery.id, + eatery.name + ) + } else { + searchViewModel.removeFavorite( + eatery.id, + eatery.name + ) + } } }) { searchViewModel.addRecentSearch(it.id) @@ -290,10 +298,12 @@ fun SearchScreen( favoriteEatery.id == eatery.id }, onFavoriteClick = { - if (it) { - searchViewModel.addFavorite(eatery.id) - } else { - searchViewModel.removeFavorite(eatery.id) + if (eatery.id != null && eatery.name != null) { + if (it) { + searchViewModel.addFavorite(eatery.id, eatery.name) + } else { + searchViewModel.removeFavorite(eatery.id, eatery.name) + } } }) { searchViewModel.addRecentSearch(it.id) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt index 7650b509..8fbcb363 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt @@ -139,11 +139,12 @@ class EateryDetailViewModel @Inject constructor( fun toggleFavorite() { when (val eateryState = eateryDetailViewState.value) { is EateryDetailViewState.Loaded -> { + val eateryName = eateryState.eatery.name ?: return viewModelScope.launch { if (eateryState.isFavorite) { - userRepository.removeFavoriteEatery(eateryId) + userRepository.removeFavoriteEatery(eateryId, eateryName) } else { - userRepository.addFavoriteEatery(eateryId) + userRepository.addFavoriteEatery(eateryId, eateryName) } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt index efc78a94..cad8a89b 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt @@ -69,17 +69,22 @@ class FavoritesViewModel @Inject constructor( */ val favoritesScreenViewState: StateFlow = combine( eateryRepository.eateryFlow, + userRepository.favoriteEateriesFlow, userRepository.favoriteItemsFlow, selectedEateryFiltersFlow, selectedItemFiltersFlow - ) { apiResponse, favoriteItems, selectedEateryFilters, selectedItemFilters -> + ) { apiResponse, favoriteEateries, favoriteItems, selectedEateryFilters, selectedItemFilters -> when (apiResponse) { is EateryApiResponse.Error -> FavoritesScreenViewState.Error is EateryApiResponse.Pending -> FavoritesScreenViewState.Loading is EateryApiResponse.Success -> { val allEateries = apiResponse.data - val filteredEateries = apiResponse.data.filter { + val favoriteEateryObjects = allEateries.filter { eatery -> + eatery.name in favoriteEateries + } + + val filteredEateries = favoriteEateryObjects.filter { Filter.passesSelectedFilters( allEateryFilters, selectedEateryFilters, FilterData( eatery = it, @@ -89,7 +94,7 @@ class FavoritesViewModel @Inject constructor( val menuItemsToEateries: Map> = favoriteItems.associateWith { itemName -> - allEateries.filter { eatery -> + favoriteEateryObjects.filter { eatery -> val todayEvents = eatery.events?.filter { (it.endTimestamp ?: LocalDateTime.MAX) < LocalDateTime.now() .withHour(23) @@ -165,12 +170,10 @@ class FavoritesViewModel @Inject constructor( } fun toggleFavoriteMenuItem(menuItemName: String) = viewModelScope.launch { - viewModelScope.launch { - if (menuItemName in userRepository.favoriteItemsFlow.value) { - userRepository.removeFavoriteItem(menuItemName) - } else { - userRepository.addFavoriteItem(menuItemName) - } + if (menuItemName in userRepository.favoriteItemsFlow.value) { + userRepository.removeFavoriteItem(menuItemName) + } else { + userRepository.addFavoriteItem(menuItemName) } } @@ -186,11 +189,9 @@ class FavoritesViewModel @Inject constructor( } } - fun removeFavorite(eateryId: Int?) { - if (eateryId != null) { - viewModelScope.launch { - userRepository.removeFavoriteEatery(eateryId) - } + fun removeFavorite(eateryId: Int, eateryName: String) { + viewModelScope.launch { + userRepository.removeFavoriteEatery(eateryId, eateryName) } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 4a96c4db..9ebb1334 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -154,15 +154,15 @@ class HomeViewModel @Inject constructor( _filtersFlow.update { emptyList() } } - fun addFavoriteEatery(eateryId: Int) { + fun addFavoriteEatery(eateryId: Int, eateryName: String) { viewModelScope.launch { - userRepository.addFavoriteEatery(eateryId) + userRepository.addFavoriteEatery(eateryId, eateryName) } } - fun removeFavoriteEatery(eateryId: Int) { + fun removeFavoriteEatery(eateryId: Int, eateryName: String) { viewModelScope.launch { - userRepository.removeFavoriteEatery(eateryId) + userRepository.removeFavoriteEatery(eateryId, eateryName) } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt index 7b3df307..5e17c992 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt @@ -62,14 +62,12 @@ class NearestViewModel @Inject constructor( /** * Changes the favorite status of the given eatery. */ - fun setFavorite(eateryId: Int?, favorite: Boolean) { - if (eateryId != null) { - viewModelScope.launch { - if (favorite) { - userRepository.addFavoriteEatery(eateryId) - } else { - userRepository.removeFavoriteEatery(eateryId) - } + fun setFavorite(eateryId: Int, eateryName: String, favorite: Boolean) { + viewModelScope.launch { + if (favorite) { + userRepository.addFavoriteEatery(eateryId, eateryName) + } else { + userRepository.removeFavoriteEatery(eateryId, eateryName) } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt index b6bbdbb0..60f79c09 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt @@ -134,19 +134,15 @@ class SearchViewModel @Inject constructor( return nameMatch || menuMatch } - fun addFavorite(eateryId: Int?) { - if (eateryId != null) { - viewModelScope.launch { - userRepository.addFavoriteEatery(eateryId) - } + fun addFavorite(eateryId: Int, eateryName: String) { + viewModelScope.launch { + userRepository.addFavoriteEatery(eateryId, eateryName) } } - fun removeFavorite(eateryId: Int?) { - if (eateryId != null) { - viewModelScope.launch { - userRepository.removeFavoriteEatery(eateryId) - } + fun removeFavorite(eateryId: Int, eateryName: String) { + viewModelScope.launch { + userRepository.removeFavoriteEatery(eateryId, eateryName) } } From 60f3e3339c4c14eafe4129a6a8885c336e4eff95 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 2 Mar 2026 14:10:50 -0500 Subject: [PATCH 060/126] Fix logout behavior --- .../android/eatery/ui/screens/SettingsScreen.kt | 5 +++-- .../android/eatery/ui/viewmodels/LoginViewModel.kt | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt index c09e3067..cf2c8567 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt @@ -262,8 +262,9 @@ fun SettingsScreen( ) { Button( onClick = { - loginViewModel.onLogoutPressed() - destinations[Routes.PROFILE]?.invoke() + loginViewModel.onLogoutPressed(onDone = { + destinations[Routes.PROFILE]?.invoke() + }) }, shape = RoundedCornerShape(25.dp), colors = ButtonDefaults.buttonColors( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index fa0362cc..56550e99 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -113,10 +113,13 @@ class LoginViewModel @Inject constructor( } - fun onLogoutPressed() { + fun onLogoutPressed(onDone: () -> Unit = {}) { val newState = State.Login() _state.value = newState - viewModelScope.launch { userRepository.logout() } + viewModelScope.launch { + userRepository.logout() + onDone() + } } fun onLoginWebViewSuccess(sessionId: String) { From b756317635f620ac20d4f3058c668e149841638a Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 2 Mar 2026 14:16:56 -0500 Subject: [PATCH 061/126] Fix meal filter formatting --- .../android/eatery/ui/components/general/Filter.kt | 4 ++++ .../android/eatery/ui/screens/UpcomingMenuScreen.kt | 5 +---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt index 348a9e95..4dbc5a2d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt @@ -170,4 +170,8 @@ enum class MealFilter(val text: List, val endTimes: Float) { LUNCH(listOf("LUNCH", "BRUNCH", "LATE_LUNCH"), 16f), DINNER(listOf("DINNER"), 20.5f), LATE_DINNER(listOf("LATE_NIGHT"), 22.5f); + + val displayName: String + get() = name.split('_') + .joinToString(" ") { it.lowercase().replaceFirstChar { char -> char.uppercase() } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt index 5aa0011c..29450579 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt @@ -288,10 +288,7 @@ private fun UpcomingFilterRow( } }, selected = true, - text = when (mealFilter) { - MealFilter.LATE_DINNER -> "Late Dinner" - else -> mealFilter.text.first() - }, + text = mealFilter.displayName, icon = Icons.Default.ExpandMore ) } From b5af0f458492d951ae2cbe3c2bf5c3a8b2e0f9b5 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 4 Mar 2026 13:24:13 -0500 Subject: [PATCH 062/126] Fix capitalized meal times in details screen --- .../android/eatery/data/models/Eatery.kt | 45 +++++--------- .../details/EateryMenusBottomSheet.kt | 12 ++-- .../ui/components/general/EateryCard.kt | 2 +- .../eatery/ui/components/general/Filter.kt | 2 +- .../ui/components/upcoming/MealBottomSheet.kt | 8 +-- .../ui/navigation/MainTabbedNavigation.kt | 17 ----- .../eatery/ui/screens/EateryDetailScreen.kt | 62 ++----------------- .../ui/viewmodels/EateryDetailViewModel.kt | 2 +- .../eatery/ui/viewmodels/HomeViewModel.kt | 2 +- .../eatery/ui/viewmodels/NearestViewModel.kt | 3 +- .../android/eatery/util/DateUtil.kt | 8 +++ 11 files changed, 46 insertions(+), 117 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt index 0e4bba72..37e6a6b0 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt @@ -2,7 +2,6 @@ package com.cornellappdev.android.eatery.data.models import android.location.Location import androidx.compose.ui.graphics.Color -import com.cornellappdev.android.eatery.ui.components.general.MealFilter import com.cornellappdev.android.eatery.util.Constants.AVERAGE_WALK_SPEED import com.cornellappdev.android.eatery.util.LocationHandler import com.squareup.moshi.Json @@ -46,7 +45,7 @@ data class Eatery( val waitTimes: List? = null, val alerts: List? = null, ) { - fun getWalkTimes(): Int? { + fun getWalkTimeInMinutes(): Int? { val currentLocation = LocationHandler.currentLocation.value val results = floatArrayOf(0f) if (latitude == null || longitude == null || currentLocation == null) { @@ -114,7 +113,6 @@ data class Eatery( ?: todayEvents.lastOrNull() } - /** * @returns the event that makes the day index and mealDescription * @@ -140,9 +138,8 @@ data class Eatery( * for louies, it returns [("General",some string duration)] * Note, string duration are in the format "11:00 AM - 2:30 PM" */ - fun getTypeMeal(currSelectedDay: DayOfWeek): List> { + fun getTypeMeal(currSelectedDay: DayOfWeek): List { val timeFormatter = DateTimeFormatter.ofPattern("h:mm a") - val uniqueMeals = LinkedHashMap() events?.filter { it.startTimestamp?.dayOfWeek == currSelectedDay } @@ -160,7 +157,7 @@ data class Eatery( } } - return uniqueMeals.toList() + return uniqueMeals.map { (description, duration) -> MealTime(description, duration) } } /** @@ -176,14 +173,6 @@ data class Eatery( } } - fun getSelectedDayMeal(meal: MealFilter, day: Int): List? { - var currentDay = LocalDate.now() - currentDay = currentDay.plusDays(day.toLong()) - return events?.filter { event -> - currentDay.dayOfYear == event.startTimestamp?.dayOfYear && meal.text.contains(event.type) - } - } - private fun getCurrentEvents(): List { val currentTime = LocalDateTime.now() if (events.isNullOrEmpty()) @@ -206,9 +195,7 @@ data class Eatery( return "${endTime.format(DateTimeFormatter.ofPattern("K:mm a"))}" } - fun isClosed(): Boolean { - return getOpenUntil() == null - } + fun isClosed(): Boolean = getOpenUntil() == null /** * Returns true if the eatery has a current event and that event is ending within [minutes]. @@ -259,21 +246,13 @@ data class Eatery( fun acceptsBRB(): Boolean = paymentMethods?.contains(PaymentMethod.BRB) ?: false -// fun acceptsMealSwipes(): Boolean = paymentMethods?.contains("MEAL_SWIPE") ?: false -// -// fun acceptsCard(): Boolean = paymentMethods?.contains("CARD") ?: false -// -// fun acceptsCash(): Boolean = paymentMethods?.contains("CASH") ?: false -// -// fun acceptsBRB(): Boolean = paymentMethods?.contains("BRB") ?: false - /** * Private helper function that returns a map of the day of week that a eatery is open * to the opening time(s) or closed status (these are strings) * * e.g. For Oken, {Monday -> ["11:00 AM - 2:30 PM", "4:30 PM - 9:00 PM"], Sunday -> "Closed"} */ - private fun operatingHours(): Map> { + private fun operatingHours(): Map> { val dailyHours = mutableMapOf>() events?.forEach { event -> @@ -323,7 +302,6 @@ data class Eatery( "Thursday" to 5, "Friday" to 6, "Saturday" to 7 - ) /** @@ -339,7 +317,7 @@ data class Eatery( * together; then, these groups, along with days with unique opening times compared * to its neighbor days, are each mapped to the corresponding opening times */ - private fun groupedHoursFormatHelper(groupedHours: Map, List>): List>> { + private fun groupedHoursFormatHelper(groupedHours: Map, List>): List>> { val formattedHours = LinkedHashMap>() groupedHours.forEach { entry -> @@ -485,3 +463,14 @@ data class EateryStatus( val statusText: String, val statusColor: Color, ) + +/** + * Represents a meal with its description and time duration. + * @param mealType The meal type (e.g., "Lunch", "Dinner", "Breakfast") + * @param duration The meal time range in the format "HH:MM AM/PM - HH:MM AM/PM" (e.g., "11:00 AM - 2:30 PM") + */ +data class MealTime( + val mealType: String, + val duration: String, +) + diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt index 61463cbf..611de026 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt @@ -37,10 +37,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.MealTime import com.cornellappdev.android.eatery.ui.components.general.CalendarWeekSelector import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.theme.GrayZero +import com.cornellappdev.android.eatery.util.toMealTypeDisplayName import com.cornellappdev.android.eatery.util.toReadableShortName import java.time.DayOfWeek import java.time.LocalDate @@ -95,9 +97,9 @@ fun EateryMenusBottomSheet( } val selectedDayOfWeek = DayOfWeek.of(dayWeeks[currSelectedDay]) - val mealTypes: List> = eatery.getTypeMeal(selectedDayOfWeek) + val mealTypes: List = eatery.getTypeMeal(selectedDayOfWeek) var selectedMealType by remember { - mutableStateOf(mealTypes[mealType].first) + mutableStateOf(mealTypes[mealType].mealType) } Card( @@ -147,7 +149,7 @@ fun EateryMenusBottomSheet( days = days, onClick = { i -> currSelectedDay = i - selectedMealType = mealTypes.firstOrNull()?.first ?: "" + selectedMealType = mealTypes.firstOrNull()?.mealType ?: "" }, modifier = Modifier.padding(bottom = 12.dp), closedDays = closedDaysStrings, @@ -170,7 +172,7 @@ fun EateryMenusBottomSheet( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = description, + text = description.toMealTypeDisplayName(), fontSize = 18.sp, fontWeight = FontWeight(600), color = Color.Black, @@ -234,7 +236,7 @@ fun EateryMenusBottomSheet( onShowMenuClick( currSelectedDay, selectedMealType, - mealTypes.indexOfFirst { it.first == selectedMealType }) + mealTypes.indexOfFirst { it.mealType == selectedMealType }) onDismiss() }, modifier = Modifier diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt index 49920c6a..542e1491 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt @@ -293,7 +293,7 @@ fun EateryCardPrimaryHeader(eatery: Eatery, style: EateryCardStyle = EateryCardS @Composable fun EateryCardSecondaryHeader(eatery: Eatery, style: EateryCardStyle = EateryCardStyle.DEFAULT) { if (style != EateryCardStyle.COMPACT) { - val walkText = eatery.getWalkTimes()?.let { + val walkText = eatery.getWalkTimeInMinutes()?.let { "${if (it > 0) it else "< 1"} min walk" } Row( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt index 4dbc5a2d..6de9ddfa 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt @@ -74,7 +74,7 @@ sealed class Filter(open val text: String) { data object Under10 : FromEateryFilter(text = "Under 10 min") { override fun passesEateryFilter(eatery: Eatery): Boolean = - eatery.getWalkTimes()?.let { it <= 10 } == true + eatery.getWalkTimeInMinutes()?.let { it <= 10 } == true } data object Swipes : FromEateryFilter(text = "Swipes") { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt index 75e520d1..bfd89c0a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/upcoming/MealBottomSheet.kt @@ -93,7 +93,7 @@ fun MealBottomSheet( ) { Column { Text( - text = "Breakfast", + text = MealFilter.BREAKFAST.displayName, modifier = Modifier.padding(start = 16.dp), style = EateryBlueTypography.h5 ) @@ -143,7 +143,7 @@ fun MealBottomSheet( ) { Column { Text( - text = "Lunch", + text = MealFilter.LUNCH.displayName, modifier = Modifier.padding(start = 16.dp), style = EateryBlueTypography.h5 ) @@ -194,7 +194,7 @@ fun MealBottomSheet( ) { Column { Text( - text = "Dinner", + text = MealFilter.DINNER.displayName, modifier = Modifier.padding(start = 16.dp), style = EateryBlueTypography.h5 ) @@ -243,7 +243,7 @@ fun MealBottomSheet( ) { Column { Text( - text = "Late Dinner", + text = MealFilter.LATE_DINNER.displayName, modifier = Modifier.padding(start = 16.dp), style = EateryBlueTypography.h5 ) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt index 0839de23..3b8b603e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/navigation/MainTabbedNavigation.kt @@ -340,24 +340,7 @@ fun SetupNavHost( } } ), -// loginViewModel = loginViewModel ) - - } - composable( - route = Routes.ACCOUNT.route, - enterTransition = { - fadeIn( - initialAlpha = 0f, - animationSpec = tween(durationMillis = 500) - ) - }, - exitTransition = { - fadeOut( - animationSpec = tween(durationMillis = 500) - ) - }) { backStackEntry -> - // TODO account page } composable( route = Routes.ABOUT.route, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt index b5a86cd8..c0fd49e8 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt @@ -113,9 +113,8 @@ import com.cornellappdev.android.eatery.ui.theme.colorInterp import com.cornellappdev.android.eatery.ui.viewmodels.EateryDetailViewModel import com.cornellappdev.android.eatery.ui.viewmodels.EateryDetailViewState import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse -import com.cornellappdev.android.eatery.util.AppStorePopupRepository -import com.cornellappdev.android.eatery.util.appStorePopupRepository import com.cornellappdev.android.eatery.util.fromOffsetToDayOfWeek +import com.cornellappdev.android.eatery.util.toMealTypeDisplayName import com.cornellappdev.android.eatery.util.toReadableFullName import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer @@ -128,7 +127,6 @@ import java.time.format.DateTimeFormatter @Composable fun EateryDetailScreen( eateryDetailViewModel: EateryDetailViewModel = hiltViewModel(), - appStorePopupRepository: AppStorePopupRepository = appStorePopupRepository(), onCompareMenusClick: (selectedEateriesIds: List) -> Unit, ) { val shimmer = rememberShimmer(ShimmerBounds.View) @@ -237,11 +235,11 @@ fun EateryDetailScreen( ReportBottomSheet( issue = issue, eateryid = it, - sendReport = { issue, report, eateryid -> + sendReport = { issue, report, eateryId -> eateryDetailViewModel.sendReport( issue, report, - eateryid + eateryId ) }) { coroutineScope.launch { @@ -602,61 +600,9 @@ fun EateryDetailScreen( .fillMaxHeight(0.5f) .width(1.dp) ) - //todo get rid of this? - - - // Column( -// horizontalAlignment = Alignment.CenterHorizontally, -// modifier = Modifier -// .padding(vertical = 12.dp) -// .weight(1f, true) -// ) { -// Row( -// verticalAlignment = Alignment.CenterVertically, -// modifier = Modifier.clickable { -// sheetContent = BottomSheetContent.WAIT_TIME -// coroutineScope.launch { -// modalBottomSheetState.show() -// } -// } -// ) { -// Icon( -// imageVector = Icons.Default.HourglassTop, -// contentDescription = "Watch Icon", -// tint = GrayFive -// ) -// Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) -// Text( -// text = "Wait Time", -// style = TextStyle( -// fontWeight = FontWeight.SemiBold, -// fontSize = 16.sp -// ), -// color = GrayFive -// ) -// } -// -// val waitTimes = eatery.getWaitTimes() -// Text( -// modifier = Modifier.padding(top = 2.dp), -// text = if (!waitTimes.isNullOrEmpty() && !eatery.isClosed()) { -// "$waitTimes minutes" -// } else { -// "-" -// }, -// style = TextStyle( -// fontWeight = FontWeight.SemiBold, -// fontSize = 16.sp -// ), -// color = Color.Black, -// ) -// -// -// } } } - item { Spacer( modifier = Modifier @@ -703,7 +649,7 @@ fun EateryDetailScreen( } eatery.getTypeMeal(viewState.weekdayIndex.fromOffsetToDayOfWeek()) .takeIf { it.size > 1 } - ?.map { it.first } + ?.map { it.mealType.toMealTypeDisplayName() } ?.let { mealTypes -> item { EateryMealTabs( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt index 8fbcb363..233831a5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt @@ -33,7 +33,7 @@ sealed class EateryDetailViewState { val weekdayIndex: Int, ) : EateryDetailViewState() { val mealTypeIndex: Int = eatery.getTypeMeal(weekdayIndex.fromOffsetToDayOfWeek()) - .indexOfFirst { it.first == mealToShow.description }.coerceAtLeast(0) + .indexOfFirst { it.mealType == mealToShow.description }.coerceAtLeast(0) } data class Error(val message: String) : EateryDetailViewState() diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 9ebb1334..433525c8 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -120,7 +120,7 @@ class HomeViewModel @Inject constructor( is EateryApiResponse.Error -> listOf() is EateryApiResponse.Pending -> listOf() is EateryApiResponse.Success -> { - apiResponse.data.sortedBy { it.getWalkTimes() }.sortedBy { it.isClosed() } + apiResponse.data.sortedBy { it.getWalkTimeInMinutes() }.sortedBy { it.isClosed() } } } }.stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt index 5e17c992..cd2e2321 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt @@ -53,7 +53,8 @@ class NearestViewModel @Inject constructor( is EateryApiResponse.Error -> listOf() is EateryApiResponse.Pending -> listOf() is EateryApiResponse.Success -> { - apiResponse.data.sortedBy { it.getWalkTimes() }.sortedBy { it.isClosed() } + apiResponse.data.sortedBy { it.getWalkTimeInMinutes() } + .sortedBy { it.isClosed() } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/util/DateUtil.kt b/app/src/main/java/com/cornellappdev/android/eatery/util/DateUtil.kt index 3716226d..a409b748 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/util/DateUtil.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/util/DateUtil.kt @@ -22,3 +22,11 @@ fun DayOfWeek.toReadableShortName(): String = when (this) { * Example: If day of the week is Monday, and the offset is 1, it would return Tuesday */ fun Int.fromOffsetToDayOfWeek(): DayOfWeek = LocalDate.now().plusDays(this.toLong()).dayOfWeek + +/** + * Converts meal type strings from the API (e.g., "BREAKFAST", "LATE_DINNER") to display names + * (e.g., "Breakfast", "Late Dinner") + */ +fun String.toMealTypeDisplayName(): String = + split('_') + .joinToString(" ") { it.lowercase().replaceFirstChar { char -> char.uppercase() } } From c995b426eeccc2e2a3f45de8a47edb670d1f80ce Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 4 Mar 2026 13:53:59 -0500 Subject: [PATCH 063/126] Improve code quality in Eatery --- .../android/eatery/data/models/Eatery.kt | 95 +++++++------------ 1 file changed, 33 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt index 37e6a6b0..cf3e828c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt @@ -18,7 +18,13 @@ import java.time.Duration import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.format.TextStyle import java.util.Date +import java.util.Locale + +typealias DayToOperatingHours = Map> +typealias OperatingHoursToDays = Map, List> +typealias FormattedOperatingHours = List>> @JsonClass(generateAdapter = true) data class Eatery( @@ -134,8 +140,8 @@ data class Eatery( * on chronological order and the duration of that particular meal * e.g. for Oken on Mondays, it would return * [("Lunch", some string duration),("Dinner", some string duration)] - * note, for cafes, it would just return [("Open",some string duration)], - * for louies, it returns [("General",some string duration)] + * note, for cafés, it would just return [("Open",some string duration)], + * for Louie's, it returns [("General",some string duration)] * Note, string duration are in the format "11:00 AM - 2:30 PM" */ fun getTypeMeal(currSelectedDay: DayOfWeek): List { @@ -164,7 +170,7 @@ data class Eatery( * Returns the list of DayOfWeek that this eatery is closed */ fun getClosedDays(): List { - val dailyHours = operatingHours() + val dailyHours = getOperatingHours() return dailyHours.filter { (_, times) -> "Closed" in times @@ -252,7 +258,7 @@ data class Eatery( * * e.g. For Oken, {Monday -> ["11:00 AM - 2:30 PM", "4:30 PM - 9:00 PM"], Sunday -> "Closed"} */ - private fun operatingHours(): Map> { + private fun getOperatingHours(): DayToOperatingHours { val dailyHours = mutableMapOf>() events?.forEach { event -> @@ -265,78 +271,54 @@ data class Eatery( dailyHours.computeIfAbsent(dayOfWeek) { mutableListOf() }.add(timeString) } } - DayOfWeek.entries.forEach { dayOfWeek -> dailyHours.computeIfAbsent(dayOfWeek) { mutableListOf("Closed") } } - return dailyHours } - /**@Return a list of pairs (association list) representing the day(s) of a week - * and the corresponding times that a eatery is open + /**@Return A list of pairs (association list) representing the day(s) of a week + * and the corresponding times that an eatery is open * - * this is computed by first mapping each dayOfWeek in each element of events to - * corresponding opening times (with helper operatingHours()), - * then a helper (groupedHoursFormatHelper) to group - * daysOfWeek with the same list of opening times into the association list of - * day(s) mapped to opening hours. */ - fun formatOperatingHours(): List>> { - val dailyHours = operatingHours() - + fun formatOperatingHours(): FormattedOperatingHours { + val dailyHours = getOperatingHours() val groupedHours = dailyHours.entries.groupBy({ it.value }, { it.key }) - return groupedHoursFormatHelper(groupedHours) } - /** - * value to represent the custom order of days in a week (with Sunday as - * the first day due to a particular design choice). Used for sorting purposes - */ - private val dayOrder = mapOf( - "Sunday" to 1, - "Monday" to 2, - "Tuesday" to 3, - "Wednesday" to 4, - "Thursday" to 5, - "Friday" to 6, - "Saturday" to 7 - ) - /** * @Return a list of pairs (association list) representing the day(s) of a week - * and the corresponding times that a eatery is open. The list of pairs is sorted - * by key (day(s)) with the custom dayOrder + * and the corresponding times that an eatery is open. The list of pairs is sorted + * by key (day(s)) with Sunday as the first day of the week * * @Param groupedHours: a Map data structure mapping a MutableList of strings - * representing the opening times of a eatery to the days that opens at those + * representing the opening times of an eatery to the days that opens at those * opening times * * The function groups consecutive days of the week that share the same opening time * together; then, these groups, along with days with unique opening times compared * to its neighbor days, are each mapped to the corresponding opening times */ - private fun groupedHoursFormatHelper(groupedHours: Map, List>): List>> { + private fun groupedHoursFormatHelper(groupedHours: OperatingHoursToDays): FormattedOperatingHours { val formattedHours = LinkedHashMap>() - groupedHours.forEach { entry -> - val days = entry.value.sortedBy { - val dayName = "$it".take(1).uppercase() + "$it".drop(1).lowercase() - dayOrder[dayName] ?: Int.MAX_VALUE + groupedHours.forEach { (operatingHours, daysOfTheWeek) -> + val days = daysOfTheWeek.sortedBy { dayOfWeek -> + dayOfWeek.value % 7 } var curStrings = mutableListOf() - for (i in (days.indices)) { - val curDay = "${days[i]}".take(1).uppercase() + "${days[i]}".drop(1).lowercase() + for (i in days.indices) { + val curDay = days[i].getDisplayName(TextStyle.FULL, Locale.getDefault()) if (i == days.size - 1) { curStrings.add(curDay) val formattedString = formatString(curStrings) - formattedHours[formattedString] = entry.key + formattedHours[formattedString] = operatingHours } else { curStrings.add(curDay) - if (!isNext("${days[i]}", "${days[i + 1]}")) { + if (!isNext(days[i], days[i + 1])) { val formattedString = formatString(curStrings) - formattedHours[formattedString] = entry.key + formattedHours[formattedString] = operatingHours curStrings = mutableListOf() } } @@ -345,30 +327,19 @@ data class Eatery( val formattedHoursList = formattedHours.toList().sortedBy { entry -> val firstDay = entry.first.split(" to ", " ", limit = 2).first() - dayOrder[firstDay] ?: Int.MAX_VALUE + // Find the matching day and get its sort value + DayOfWeek.entries.find { + it.getDisplayName(TextStyle.FULL, Locale.getDefault()) == firstDay + }?.let { (it.value % 7) } ?: Int.MAX_VALUE } return formattedHoursList } /** - * @Return Boolean representing if day2 is right (consecutively) after - * day1 - * - * @Param day1: a all capitalized string representing a day in the week - * @Param day2: a all capitalized string representing a day in the week + * @Return Whether day2 follows day1 */ - private fun isNext(day1: String, day2: String): Boolean { - return when (day1) { - "MONDAY" -> day2 == "TUESDAY" - "TUESDAY" -> day2 == "WEDNESDAY" - "WEDNESDAY" -> day2 == "THURSDAY" - "THURSDAY" -> day2 == "FRIDAY" - "FRIDAY" -> day2 == "SATURDAY" - "SATURDAY" -> false - "SUNDAY" -> day2 == "MONDAY" - else -> false - } - } + private fun isNext(day1: DayOfWeek, day2: DayOfWeek): Boolean = + ((day1.value % 7) + 1) % 7 == (day2.value % 7) /** * @Return formatted string representing the duration of days of a week From a99f6cd786cb573e683fdf40da099312e2f76f8b Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 4 Mar 2026 14:42:26 -0500 Subject: [PATCH 064/126] Refresh favorites when HomeScreen opens --- .../com/cornellappdev/android/eatery/MainActivity.kt | 1 + .../eatery/data/repositories/UserRepository.kt | 11 +++++++++++ .../android/eatery/ui/screens/HomeScreen.kt | 4 ++++ .../android/eatery/ui/viewmodels/HomeViewModel.kt | 8 ++++++++ 4 files changed, 24 insertions(+) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 4b453139..9fc41557 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -47,6 +47,7 @@ class MainActivity : ComponentActivity() { runBlocking { configureTokens() userRepository.updateFavorites() + userRepository.markTokensAsConfigured() } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 33fb931b..50289ed3 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -47,6 +47,13 @@ class UserRepository @Inject constructor( */ val favoriteItemsFlow: StateFlow> = _favoriteItemsFlow.asStateFlow() + private val _tokensConfiguredFlow: MutableStateFlow = MutableStateFlow(false) + + /** + * A [StateFlow] that emits whether configureTokens() has completed successfully. + */ + val tokensConfiguredFlow: StateFlow = _tokensConfiguredFlow.asStateFlow() + suspend fun hasLaunchedBefore(): Boolean = userPreferencesRepository.getDeviceId() != null suspend fun getDeviceId(): String { @@ -84,6 +91,10 @@ class UserRepository @Inject constructor( } } + fun markTokensAsConfigured() { + _tokensConfiguredFlow.value = true + } + suspend fun updateFavorites() { val accessPhrase = getAccessToken() val matches = tryRequest { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 6fb43cbd..13e741fc 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -134,6 +134,10 @@ fun HomeScreen( LocationHandler.instantiate(context) } + LaunchedEffect(Unit) { + homeViewModel.updateFavoritesIfConfigured() + } + val selectedPaymentMethodFilters = remember { mutableStateListOf() } val modalBottomSheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 433525c8..30b70682 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -177,4 +177,12 @@ class HomeViewModel @Inject constructor( fun pingEateries() { eateryRepository.pingEateries() } + + fun updateFavoritesIfConfigured() { + if (userRepository.tokensConfiguredFlow.value) { + viewModelScope.launch { + userRepository.updateFavorites() + } + } + } } From 49289437f7c7f48c48f26abcdf8992bd4ec63cfa Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 4 Mar 2026 23:22:59 -0500 Subject: [PATCH 065/126] Implement in-app updates --- app/build.gradle | 4 + .../android/eatery/MainActivity.kt | 125 ++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/app/build.gradle b/app/build.gradle index 084f7a4a..c7dc99ec 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,6 +143,10 @@ dependencies { implementation 'com.valentinilk.shimmer:compose-shimmer:1.0.3' implementation "io.coil-kt:coil-compose:2.2.2" + + // In-app Updates + implementation("com.google.android.play:app-update:2.1.0") + implementation("com.google.android.play:app-update-ktx:2.1.0") } protobuf { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 9fc41557..9eadb929 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -1,8 +1,12 @@ package com.cornellappdev.android.eatery import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.WindowCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -10,6 +14,12 @@ import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.navigation.NavigationSetup import com.cornellappdev.android.eatery.util.LockScreenOrientation +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.install.model.UpdateAvailability import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.runBlocking import javax.inject.Inject @@ -22,9 +32,22 @@ class MainActivity : ComponentActivity() { @Inject lateinit var userRepository: UserRepository + private lateinit var activityResultLauncher: ActivityResultLauncher + private val appUpdateManager by lazy { AppUpdateManagerFactory.create(applicationContext) } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Initialize the activity result launcher before onCreate + activityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult() + ) { result -> + // Handle the result from the update flow + if (result.resultCode != RESULT_OK) { + Log.d("MainActivity", "Update flow failed! Result code: ${result.resultCode}") + } + } + val hasOnboarded = runBlocking { userRepository.hasOnboarded() } WindowCompat.setDecorFitsSystemWindows(window, false) @@ -44,6 +67,7 @@ class MainActivity : ComponentActivity() { } } lifecycle.addObserver(dataRefresher) + checkForUpdateAvailability() runBlocking { configureTokens() userRepository.updateFavorites() @@ -51,6 +75,107 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + + // Check if there's an update that's already downloaded and waiting to be installed + appUpdateManager + .appUpdateInfo + .addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) { + // If an in-app update is already running, resume the update. + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activityResultLauncher, + AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build() + ) + } + + if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) { + // If the update is downloaded but not installed, notify the user to complete the update. + popupSnackbarForCompleteUpdate() + } + } + } + + private fun checkForUpdateAvailability() { + // Check for updates + appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + // Check if update is allowed based on priority and type + val isImmediateUpdateAllowed = + appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) + val isFlexibleUpdateAllowed = + appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) + + // For high priority updates, use immediate update + if (appUpdateInfo.updatePriority() >= 4 && isImmediateUpdateAllowed) { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activityResultLauncher, + AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build() + ) + } + // For normal priority updates, use flexible update + else if (isFlexibleUpdateAllowed) { + // Create a listener to track flexible update progress + val listener = InstallStateUpdatedListener { state -> + when (state.installStatus()) { + InstallStatus.DOWNLOADING -> { + val bytesDownloaded = state.bytesDownloaded() + val totalBytesToDownload = state.totalBytesToDownload() + Log.d( + "MainActivity", + "Update downloading: $bytesDownloaded / $totalBytesToDownload" + ) + } + + InstallStatus.DOWNLOADED -> { + Log.d("MainActivity", "Update downloaded") + popupSnackbarForCompleteUpdate() + } + + InstallStatus.INSTALLING -> { + Log.d("MainActivity", "Update installing") + } + + InstallStatus.INSTALLED -> { + Log.d("MainActivity", "Update installed") + } + + InstallStatus.FAILED -> { + Log.d("MainActivity", "Update failed") + } + + InstallStatus.CANCELED -> { + Log.d("MainActivity", "Update canceled") + } + + else -> { + Log.d("MainActivity", "Update status: ${state.installStatus()}") + } + } + } + + // Register listener before starting flexible update + appUpdateManager.registerListener(listener) + + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activityResultLauncher, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build() + ) + } + } + } + } + + private fun popupSnackbarForCompleteUpdate() { + // Show a snackbar or dialog to prompt user to complete the update + // For Compose, you might want to show a dialog instead + appUpdateManager.completeUpdate() + } + private suspend fun configureTokens() { if (!userRepository.hasLaunchedBefore()) { userRepository.registerDevice() From edb57acd40f6d6de6371efd41848a554e812cf6c Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Wed, 4 Mar 2026 23:27:18 -0500 Subject: [PATCH 066/126] Clean up comments --- .../com/cornellappdev/android/eatery/MainActivity.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 9eadb929..972c1f7f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -38,11 +38,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Initialize the activity result launcher before onCreate activityResultLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult() ) { result -> - // Handle the result from the update flow if (result.resultCode != RESULT_OK) { Log.d("MainActivity", "Update flow failed! Result code: ${result.resultCode}") } @@ -99,10 +97,8 @@ class MainActivity : ComponentActivity() { } private fun checkForUpdateAvailability() { - // Check for updates appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo -> if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { - // Check if update is allowed based on priority and type val isImmediateUpdateAllowed = appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) val isFlexibleUpdateAllowed = @@ -156,8 +152,6 @@ class MainActivity : ComponentActivity() { } } } - - // Register listener before starting flexible update appUpdateManager.registerListener(listener) appUpdateManager.startUpdateFlowForResult( @@ -171,8 +165,7 @@ class MainActivity : ComponentActivity() { } private fun popupSnackbarForCompleteUpdate() { - // Show a snackbar or dialog to prompt user to complete the update - // For Compose, you might want to show a dialog instead + // todo - Show a snackbar or dialog to prompt user to complete the update appUpdateManager.completeUpdate() } From 8c33e015dff2153a9a364f5b3b8a6dd9e9fe0c15 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 14:37:42 -0500 Subject: [PATCH 067/126] Clean up logging --- .../android/eatery/MainActivity.kt | 50 ++----------------- 1 file changed, 4 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 972c1f7f..9e71f64f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -1,7 +1,6 @@ package com.cornellappdev.android.eatery import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher @@ -40,11 +39,7 @@ class MainActivity : ComponentActivity() { activityResultLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult() - ) { result -> - if (result.resultCode != RESULT_OK) { - Log.d("MainActivity", "Update flow failed! Result code: ${result.resultCode}") - } - } + ) {} val hasOnboarded = runBlocking { userRepository.hasOnboarded() } @@ -91,7 +86,7 @@ class MainActivity : ComponentActivity() { if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) { // If the update is downloaded but not installed, notify the user to complete the update. - popupSnackbarForCompleteUpdate() + appUpdateManager.completeUpdate() } } } @@ -116,40 +111,8 @@ class MainActivity : ComponentActivity() { else if (isFlexibleUpdateAllowed) { // Create a listener to track flexible update progress val listener = InstallStateUpdatedListener { state -> - when (state.installStatus()) { - InstallStatus.DOWNLOADING -> { - val bytesDownloaded = state.bytesDownloaded() - val totalBytesToDownload = state.totalBytesToDownload() - Log.d( - "MainActivity", - "Update downloading: $bytesDownloaded / $totalBytesToDownload" - ) - } - - InstallStatus.DOWNLOADED -> { - Log.d("MainActivity", "Update downloaded") - popupSnackbarForCompleteUpdate() - } - - InstallStatus.INSTALLING -> { - Log.d("MainActivity", "Update installing") - } - - InstallStatus.INSTALLED -> { - Log.d("MainActivity", "Update installed") - } - - InstallStatus.FAILED -> { - Log.d("MainActivity", "Update failed") - } - - InstallStatus.CANCELED -> { - Log.d("MainActivity", "Update canceled") - } - - else -> { - Log.d("MainActivity", "Update status: ${state.installStatus()}") - } + if (state.installStatus() == InstallStatus.DOWNLOADED) { + appUpdateManager.completeUpdate() } } appUpdateManager.registerListener(listener) @@ -164,11 +127,6 @@ class MainActivity : ComponentActivity() { } } - private fun popupSnackbarForCompleteUpdate() { - // todo - Show a snackbar or dialog to prompt user to complete the update - appUpdateManager.completeUpdate() - } - private suspend fun configureTokens() { if (!userRepository.hasLaunchedBefore()) { userRepository.registerDevice() From 4cba7b22d17491df3997642442240bb4d6b1d1d7 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 15:08:26 -0500 Subject: [PATCH 068/126] Revert favoriting to local --- app/build.gradle | 1 + .../repositories/UserPreferencesRepository.kt | 32 ++++++ .../data/repositories/UserRepository.kt | 105 +++++++++++++----- app/src/main/proto/user_prefs.proto | 3 + 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c7dc99ec..18c103c1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -32,6 +32,7 @@ android { } buildConfigField("String", "SESSIONID_WEBVIEW_URL", secretsProperties['SESSIONID_WEBVIEW_URL']) + buildConfigField("boolean", "USE_LOCAL_FAVORITES", "true") } buildTypes { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index fab83bc9..cc262926 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -19,6 +19,7 @@ class UserPreferencesRepository @Inject constructor( private val userPreferencesStore: DataStore, ) { private val userPreferencesFlow: Flow = userPreferencesStore.data + val recentSearchesFlow: StateFlow> = userPreferencesFlow.map { prefs -> prefs.recentSearchesList }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, listOf()) @@ -51,6 +52,31 @@ class UserPreferencesRepository @Inject constructor( } } + suspend fun setFavoriteEateryName(eateryName: String, isFavorite: Boolean) { + setPref { + val updatedFavorites = favoriteEateryNamesList + .filter { it != eateryName } + .toMutableList() + + if (isFavorite) { + updatedFavorites.add(eateryName) + } + + clearFavoriteEateryNames() + addAllFavoriteEateryNames(updatedFavorites) + } + } + + suspend fun setFavoriteItemName(itemName: String, isFavorite: Boolean) { + setPref { + if (isFavorite) { + putItemFavorites(itemName, true) + } else { + removeItemFavorites(itemName) + } + } + } + suspend fun getHasOnboarded(): Boolean = userPreferencesFlow.first().hasOnboarded @@ -115,4 +141,10 @@ class UserPreferencesRepository @Inject constructor( suspend fun setSessionId(sessionId: String) { setPref { setSessionId(sessionId) } } + + suspend fun getFavoriteEateryNames(): List = + userPreferencesFlow.firstOrNull()?.favoriteEateryNamesList ?: emptyList() + + suspend fun getFavoriteItemNames(): List = + userPreferencesFlow.firstOrNull()?.itemFavoritesMap?.keys?.toList() ?: emptyList() } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 50289ed3..17efa061 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -1,5 +1,6 @@ package com.cornellappdev.android.eatery.data.repositories +import com.cornellappdev.android.eatery.BuildConfig import com.cornellappdev.android.eatery.data.NetworkApi import com.cornellappdev.android.eatery.data.models.DeviceId import com.cornellappdev.android.eatery.data.models.FavoriteEatery @@ -54,6 +55,8 @@ class UserRepository @Inject constructor( */ val tokensConfiguredFlow: StateFlow = _tokensConfiguredFlow.asStateFlow() + private val useLocalFavorites = BuildConfig.USE_LOCAL_FAVORITES + suspend fun hasLaunchedBefore(): Boolean = userPreferencesRepository.getDeviceId() != null suspend fun getDeviceId(): String { @@ -96,6 +99,12 @@ class UserRepository @Inject constructor( } suspend fun updateFavorites() { + if (useLocalFavorites) { + _favoritesEateriesFlow.value = userPreferencesRepository.getFavoriteEateryNames() + _favoriteItemsFlow.value = userPreferencesRepository.getFavoriteItemNames() + return + } + val accessPhrase = getAccessToken() val matches = tryRequest { networkApi.getFavoriteMatches(accessToken = accessPhrase) @@ -122,43 +131,83 @@ class UserRepository @Inject constructor( ) } - suspend fun addFavoriteItem(name: String) = tryRequest { - networkApi.addFavoriteItem( - accessToken = getAccessToken(), - item = FavoriteItem(item = name) - ) - _favoriteItemsFlow.update { currentItems -> - if (name !in currentItems) currentItems + name else currentItems + suspend fun addFavoriteItem(name: String) { + if (useLocalFavorites) { + userPreferencesRepository.setFavoriteItemName(name, true) + _favoriteItemsFlow.update { currentItems -> + if (name !in currentItems) currentItems + name else currentItems + } + return + } + + tryRequest { + networkApi.addFavoriteItem( + accessToken = getAccessToken(), + item = FavoriteItem(item = name) + ) + _favoriteItemsFlow.update { currentItems -> + if (name !in currentItems) currentItems + name else currentItems + } } } - suspend fun removeFavoriteItem(name: String) = tryRequest { - networkApi.deleteFavoriteItem( - accessToken = getAccessToken(), - item = FavoriteItem(name) - ) - _favoriteItemsFlow.update { currentItems -> - currentItems.filter { it != name } + suspend fun removeFavoriteItem(name: String) { + if (useLocalFavorites) { + userPreferencesRepository.setFavoriteItemName(name, false) + _favoriteItemsFlow.update { currentItems -> + currentItems.filter { it != name } + } + return + } + + tryRequest { + networkApi.deleteFavoriteItem( + accessToken = getAccessToken(), + item = FavoriteItem(name) + ) + _favoriteItemsFlow.update { currentItems -> + currentItems.filter { it != name } + } } } - suspend fun addFavoriteEatery(id: Int, eateryName: String) = tryRequest { - networkApi.addFavoriteEatery( - accessToken = getAccessToken(), - eatery = FavoriteEatery(id), - ) - _favoritesEateriesFlow.update { currentEateries -> - if (eateryName !in currentEateries) currentEateries + eateryName else currentEateries + suspend fun addFavoriteEatery(id: Int, eateryName: String) { + if (useLocalFavorites) { + userPreferencesRepository.setFavoriteEateryName(eateryName, true) + _favoritesEateriesFlow.update { currentEateries -> + if (eateryName !in currentEateries) currentEateries + eateryName else currentEateries + } + return + } + + tryRequest { + networkApi.addFavoriteEatery( + accessToken = getAccessToken(), + eatery = FavoriteEatery(id), + ) + _favoritesEateriesFlow.update { currentEateries -> + if (eateryName !in currentEateries) currentEateries + eateryName else currentEateries + } } } - suspend fun removeFavoriteEatery(id: Int, eateryName: String) = tryRequest { - networkApi.deleteFavoriteEatery( - accessToken = getAccessToken(), - eatery = FavoriteEatery(id) - ) - _favoritesEateriesFlow.update { currentEateries -> - currentEateries.filter { it != eateryName } + suspend fun removeFavoriteEatery(id: Int, eateryName: String) { + if (useLocalFavorites) { + userPreferencesRepository.setFavoriteEateryName(eateryName, false) + _favoritesEateriesFlow.update { currentEateries -> + currentEateries.filter { it != eateryName } + } + return + } + + tryRequest { + networkApi.deleteFavoriteEatery( + accessToken = getAccessToken(), + eatery = FavoriteEatery(id) + ) + _favoritesEateriesFlow.update { currentEateries -> + currentEateries.filter { it != eateryName } + } } } diff --git a/app/src/main/proto/user_prefs.proto b/app/src/main/proto/user_prefs.proto index 70c9737e..787ccd71 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -27,6 +27,9 @@ message UserPreferences { string refreshToken = 13; int32 pin = 14; + // delete once no longer local + repeated string favoriteEateryNames = 15; + // repeated int32 recentSearches = 2; // string username = 3; // // Must be encrypted / decrypted. From fb20b81ace1782475d3a3f3c14e04fa9496f2b00 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 16:23:56 -0500 Subject: [PATCH 069/126] Implement error handling system for networking calls --- .../android/eatery/data/models/Result.kt | 34 +++++ .../data/repositories/UserRepository.kt | 116 +++++++++++------- .../android/eatery/ui/screens/HomeScreen.kt | 2 +- .../eatery/ui/screens/ProfileScreen.kt | 2 +- .../ui/viewmodels/CompareMenusBotViewModel.kt | 20 ++- .../ui/viewmodels/CompareMenusViewModel.kt | 27 ++-- .../ui/viewmodels/EateryDetailViewModel.kt | 53 +++++++- .../ui/viewmodels/FavoritesViewModel.kt | 38 +++++- .../eatery/ui/viewmodels/HomeViewModel.kt | 45 ++++++- .../eatery/ui/viewmodels/LoginViewModel.kt | 74 ++++++----- .../eatery/ui/viewmodels/NearestViewModel.kt | 27 +++- .../eatery/ui/viewmodels/SearchViewModel.kt | 32 ++++- .../eatery/ui/viewmodels/SupportViewModel.kt | 32 ++++- .../ui/viewmodels/state/NetworkUiError.kt | 24 ++++ 14 files changed, 432 insertions(+), 94 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/data/models/Result.kt create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkUiError.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Result.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Result.kt new file mode 100644 index 00000000..fdd8c8e1 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Result.kt @@ -0,0 +1,34 @@ +package com.cornellappdev.android.eatery.data.models + +/** + * A generic wrapper for repository operations that can succeed or fail. + * This allows ViewModels to handle errors gracefully and inform the UI accordingly. + */ +sealed class Result { + data class Success(val data: T) : Result() + data class Error(val error: NetworkError) : Result() +} + +sealed class NetworkError { + object Unauthorized : NetworkError() { + override fun toString() = "Authentication failed. Please log in again." + } + + object NetworkFailure : NetworkError() { + override fun toString() = "Network error. Please check your connection and try again." + } + + data class ServerError(val code: Int, val message: String?) : NetworkError() { + override fun toString() = "Server error ($code): ${message ?: "Unknown error"}" + } + + object Timeout : NetworkError() { + override fun toString() = "Request timed out. Please try again." + } + + data class Unknown(val throwable: Throwable) : NetworkError() { + override fun toString() = "An unexpected error occurred: ${throwable.message}" + } +} + + diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 17efa061..5a1a742a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -8,14 +8,19 @@ import com.cornellappdev.android.eatery.data.models.FavoriteItem import com.cornellappdev.android.eatery.data.models.Financials import com.cornellappdev.android.eatery.data.models.LoginPIN import com.cornellappdev.android.eatery.data.models.LoginRequest +import com.cornellappdev.android.eatery.data.models.NetworkError import com.cornellappdev.android.eatery.data.models.RefreshRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.models.SessionID import com.cornellappdev.android.eatery.data.models.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import retrofit2.HttpException +import java.io.IOException +import java.net.SocketTimeoutException import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -70,15 +75,16 @@ class UserRepository @Inject constructor( } // called on first app launch - suspend fun registerDevice() { + suspend fun registerDevice(): Result = safeRequest { val deviceId = UUID.randomUUID() userPreferencesRepository.setDeviceId(deviceId) } // called on app launch - suspend fun getTokens() { + suspend fun getTokens(): Result = safeRequest { val deviceId = - userPreferencesRepository.getDeviceId() ?: throw Exception("Device not registered") + userPreferencesRepository.getDeviceId() + ?: throw Exception("Device not registered") val response = networkApi.verifyToken(DeviceId(deviceId)) val accessToken = response.accessToken val refreshToken = response.refreshToken @@ -98,31 +104,31 @@ class UserRepository @Inject constructor( _tokensConfiguredFlow.value = true } - suspend fun updateFavorites() { + suspend fun updateFavorites(): Result { if (useLocalFavorites) { _favoritesEateriesFlow.value = userPreferencesRepository.getFavoriteEateryNames() _favoriteItemsFlow.value = userPreferencesRepository.getFavoriteItemNames() - return + return Result.Success(Unit) } - val accessPhrase = getAccessToken() - val matches = tryRequest { - networkApi.getFavoriteMatches(accessToken = accessPhrase) - } - _favoritesEateriesFlow.value = matches.mapNotNull { it.eateryName } - _favoriteItemsFlow.value = run { - val items: MutableList = mutableListOf() - matches.forEach { (_, eateryItems) -> - if (eateryItems != null) { - items.addAll(eateryItems.mapNotNull { it.name }) + return tryRequestWithResult { + val accessPhrase = getAccessToken() + val matches = networkApi.getFavoriteMatches(accessToken = accessPhrase) + _favoritesEateriesFlow.value = matches.mapNotNull { it.eateryName } + _favoriteItemsFlow.value = run { + val items: MutableList = mutableListOf() + matches.forEach { (_, eateryItems) -> + if (eateryItems != null) { + items.addAll(eateryItems.mapNotNull { it.name }) + } } + items.toList() } - items.toList() } } - suspend fun sendReport(issue: String, report: String, eateryID: Int?): Any = - tryRequest { + suspend fun sendReport(issue: String, report: String, eateryID: Int?): Result = + tryRequestWithResult { networkApi.sendReport( report = ReportSendBody( eatery = eateryID, @@ -131,16 +137,16 @@ class UserRepository @Inject constructor( ) } - suspend fun addFavoriteItem(name: String) { + suspend fun addFavoriteItem(name: String): Result { if (useLocalFavorites) { userPreferencesRepository.setFavoriteItemName(name, true) _favoriteItemsFlow.update { currentItems -> if (name !in currentItems) currentItems + name else currentItems } - return + return Result.Success(Unit) } - tryRequest { + return tryRequestWithResult { networkApi.addFavoriteItem( accessToken = getAccessToken(), item = FavoriteItem(item = name) @@ -151,16 +157,16 @@ class UserRepository @Inject constructor( } } - suspend fun removeFavoriteItem(name: String) { + suspend fun removeFavoriteItem(name: String): Result { if (useLocalFavorites) { userPreferencesRepository.setFavoriteItemName(name, false) _favoriteItemsFlow.update { currentItems -> currentItems.filter { it != name } } - return + return Result.Success(Unit) } - tryRequest { + return tryRequestWithResult { networkApi.deleteFavoriteItem( accessToken = getAccessToken(), item = FavoriteItem(name) @@ -171,16 +177,16 @@ class UserRepository @Inject constructor( } } - suspend fun addFavoriteEatery(id: Int, eateryName: String) { + suspend fun addFavoriteEatery(id: Int, eateryName: String): Result { if (useLocalFavorites) { userPreferencesRepository.setFavoriteEateryName(eateryName, true) _favoritesEateriesFlow.update { currentEateries -> if (eateryName !in currentEateries) currentEateries + eateryName else currentEateries } - return + return Result.Success(Unit) } - tryRequest { + return tryRequestWithResult { networkApi.addFavoriteEatery( accessToken = getAccessToken(), eatery = FavoriteEatery(id), @@ -191,16 +197,16 @@ class UserRepository @Inject constructor( } } - suspend fun removeFavoriteEatery(id: Int, eateryName: String) { + suspend fun removeFavoriteEatery(id: Int, eateryName: String): Result { if (useLocalFavorites) { userPreferencesRepository.setFavoriteEateryName(eateryName, false) _favoritesEateriesFlow.update { currentEateries -> currentEateries.filter { it != eateryName } } - return + return Result.Success(Unit) } - tryRequest { + return tryRequestWithResult { networkApi.deleteFavoriteEatery( accessToken = getAccessToken(), eatery = FavoriteEatery(id) @@ -211,11 +217,11 @@ class UserRepository @Inject constructor( } } - suspend fun linkGETAccount(sessionId: String) { + suspend fun linkGETAccount(sessionId: String): Result { userPreferencesRepository.setSessionId(sessionId) val pin = Random.nextInt(10000) userPreferencesRepository.setPin(pin) - tryRequest { + return tryRequestWithResult { networkApi.authorizeUser( accessToken = getAccessToken(), loginRequest = LoginRequest(pin.toString(), sessionId) @@ -223,7 +229,7 @@ class UserRepository @Inject constructor( } } - suspend fun getFinancials(): Financials = tryRequest { + suspend fun getFinancials(): Result = tryRequestWithResult { var financials: Financials try { financials = networkApi.getFinancials( @@ -249,13 +255,13 @@ class UserRepository @Inject constructor( /** * Refreshes GET sessionID. */ - suspend fun refreshLogin(pin: Int) = tryRequest { + suspend fun refreshLogin(pin: Int): Result = tryRequestWithResult { val newSessionId = networkApi.refreshAuthorizedUser( accessToken = getAccessToken(), loginPIN = LoginPIN(pin.toString()) ).sessionId if (newSessionId == null) { - // todo - handle + throw Exception("Session ID is null") } else { userPreferencesRepository.setSessionId(newSessionId) } @@ -269,19 +275,45 @@ class UserRepository @Inject constructor( suspend fun hasOnboarded(): Boolean = userPreferencesRepository.getHasOnboarded() + /** + * Converts exceptions into appropriate [NetworkError] types. + */ + private fun handleException(e: Exception): NetworkError = when (e) { + is HttpException -> when (e.code()) { + 401, 403 -> NetworkError.Unauthorized + in 400..599 -> NetworkError.ServerError(e.code(), e.message()) + else -> NetworkError.Unknown(e) + } + + is SocketTimeoutException -> NetworkError.Timeout + is IOException -> NetworkError.NetworkFailure + else -> NetworkError.Unknown(e) + } + + /** + * Safely executes a network request and wraps the result in a [Result] object. + */ + private suspend fun safeRequest(request: suspend () -> T): Result { + return try { + Result.Success(request()) + } catch (e: Exception) { + Result.Error(handleException(e)) + } + } + /** * Tries to make the given request, and if it fails, refreshes tokens and tries again. + * Returns a [Result] wrapping the response or error. */ - private suspend fun tryRequest(request: suspend () -> T): T { - try { - return request() + private suspend fun tryRequestWithResult(request: suspend () -> T): Result { + return try { + Result.Success(request()) } catch (_: Exception) { try { refreshTokens() - return request() - } catch (e: Exception) { - // todo - pass in handler - throw e + Result.Success(request()) + } catch (retryException: Exception) { + Result.Error(handleException(retryException)) } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 13e741fc..64096693 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -135,7 +135,7 @@ fun HomeScreen( } LaunchedEffect(Unit) { - homeViewModel.updateFavoritesIfConfigured() + homeViewModel.updateFavoritesIfTokensConfigured() } val selectedPaymentMethodFilters = remember { mutableStateListOf() } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 8e65ae46..d33a6562 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -85,7 +85,7 @@ private fun ProfileLoginScreenPreview() = EateryPreview { LoginViewModel.State.Login( netID = "aaa00", password = "myVeryLongPassword", - failureMessage = null, + failure = null, loading = false ) ProfileScreenContent( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt index ed8059bf..c4243da8 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt @@ -3,6 +3,7 @@ package com.cornellappdev.android.eatery.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.Filter @@ -10,6 +11,8 @@ import com.cornellappdev.android.eatery.ui.components.general.FilterData import com.cornellappdev.android.eatery.ui.components.general.updateFilters import com.cornellappdev.android.eatery.ui.viewmodels.state.CompareMenusUIState import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -29,6 +32,13 @@ class CompareMenusBotViewModel @Inject constructor( private val userRepository: UserRepository, ) : ViewModel() { + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + private val _compareMenusUiState = MutableStateFlow(CompareMenusUIState()) val compareMenusUiState: StateFlow = _compareMenusUiState.asStateFlow() @@ -122,7 +132,15 @@ class CompareMenusBotViewModel @Inject constructor( } fun sendReport(issue: String, report: String, eateryid: Int?) = viewModelScope.launch { - userRepository.sendReport(issue, report, eateryid) + when (val result = userRepository.sendReport(issue, report, eateryid)) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = NetworkUiError.Failed(NetworkAction.SendReport, result.error) + } + } } fun initializedFirstEatery( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusViewModel.kt index 08ebd121..bd9421b5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusViewModel.kt @@ -1,26 +1,22 @@ package com.cornellappdev.android.eatery.ui.viewmodels -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.Event +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.repositories.EateryRepository -import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository -import com.cornellappdev.android.eatery.ui.components.general.Filter -import com.cornellappdev.android.eatery.ui.viewmodels.state.CompareMenusUIState import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -30,6 +26,13 @@ class CompareMenusViewModel @Inject constructor( private val userRepository: UserRepository ) : ViewModel() { + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + lateinit var eateryFlow: StateFlow> lateinit var eventFlow: StateFlow> @@ -52,6 +55,14 @@ class CompareMenusViewModel @Inject constructor( } fun sendReport(issue: String, report: String, eateryId: Int?) = viewModelScope.launch { - userRepository.sendReport(issue, report, eateryId) + when (val result = userRepository.sendReport(issue, report, eateryId)) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = NetworkUiError.Failed(NetworkAction.SendReport, result.error) + } + } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt index 233831a5..b4803ab6 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/EateryDetailViewModel.kt @@ -5,11 +5,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.Event +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.MenuCategoryViewState import com.cornellappdev.android.eatery.ui.components.general.MenuItemViewState import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import com.cornellappdev.android.eatery.util.fromOffsetToDayOfWeek import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow @@ -67,6 +70,13 @@ class EateryDetailViewModel @Inject constructor( MutableStateFlow(EateryDetailViewState.Loading) val eateryDetailViewState = _eateryDetailsViewState.asStateFlow() + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + /** * A flow emitting the loading status of the current eatery. */ @@ -141,11 +151,27 @@ class EateryDetailViewModel @Inject constructor( is EateryDetailViewState.Loaded -> { val eateryName = eateryState.eatery.name ?: return viewModelScope.launch { - if (eateryState.isFavorite) { + val result = if (eateryState.isFavorite) { userRepository.removeFavoriteEatery(eateryId, eateryName) } else { userRepository.addFavoriteEatery(eateryId, eateryName) } + when (result) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = NetworkUiError.Failed( + if (eateryState.isFavorite) { + NetworkAction.RemoveFavoriteEatery + } else { + NetworkAction.AddFavoriteEatery + }, + result.error + ) + } + } } } @@ -160,16 +186,37 @@ class EateryDetailViewModel @Inject constructor( */ fun toggleFavoriteMenuItem(menuItem: String) { viewModelScope.launch { - if (menuItem in userRepository.favoriteItemsFlow.value) { + val isRemoving = menuItem in userRepository.favoriteItemsFlow.value + val result = if (isRemoving) { userRepository.removeFavoriteItem(menuItem) } else { userRepository.addFavoriteItem(menuItem) } + when (result) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = NetworkUiError.Failed( + if (isRemoving) NetworkAction.RemoveFavoriteItem else NetworkAction.AddFavoriteItem, + result.error + ) + } + } } } fun sendReport(issue: String, report: String, eateryId: Int?) = viewModelScope.launch { - userRepository.sendReport(issue, report, eateryId) + when (val result = userRepository.sendReport(issue, report, eateryId)) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = NetworkUiError.Failed(NetworkAction.SendReport, result.error) + } + } } fun setSelectedWeekdayIndex(weekdayIndex: Int) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt index cad8a89b..c1b57d3d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.EateryStatus +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.components.general.Filter @@ -14,10 +15,13 @@ import com.cornellappdev.android.eatery.ui.components.general.updateFilters import com.cornellappdev.android.eatery.ui.theme.GrayThree import com.cornellappdev.android.eatery.ui.theme.Green import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -64,6 +68,13 @@ class FavoritesViewModel @Inject constructor( private val selectedEateryFiltersFlow = MutableStateFlow>(emptyList()) private val selectedItemFiltersFlow = MutableStateFlow>(emptyList()) + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + /** * A flow emitting the latest UI state */ @@ -170,11 +181,25 @@ class FavoritesViewModel @Inject constructor( } fun toggleFavoriteMenuItem(menuItemName: String) = viewModelScope.launch { - if (menuItemName in userRepository.favoriteItemsFlow.value) { + val isRemoving = menuItemName in userRepository.favoriteItemsFlow.value + val result = if (isRemoving) { userRepository.removeFavoriteItem(menuItemName) } else { userRepository.addFavoriteItem(menuItemName) } + + when (result) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = NetworkUiError.Failed( + if (isRemoving) NetworkAction.RemoveFavoriteItem else NetworkAction.AddFavoriteItem, + result.error + ) + } + } } fun toggleEateryFilter(filter: FromEateryFilter) { @@ -191,7 +216,16 @@ class FavoritesViewModel @Inject constructor( fun removeFavorite(eateryId: Int, eateryName: String) { viewModelScope.launch { - userRepository.removeFavoriteEatery(eateryId, eateryName) + when (val result = userRepository.removeFavoriteEatery(eateryId, eateryName)) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = + NetworkUiError.Failed(NetworkAction.RemoveFavoriteEatery, result.error) + } + } } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 30b70682..8b713bd3 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository @@ -13,6 +14,8 @@ import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterData import com.cornellappdev.android.eatery.ui.components.general.updateFilters import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -34,6 +37,13 @@ class HomeViewModel @Inject constructor( ) : ViewModel() { private val _filtersFlow: MutableStateFlow> = MutableStateFlow(listOf()) + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + /** * A flow of filters applied to the screen. */ @@ -156,13 +166,31 @@ class HomeViewModel @Inject constructor( fun addFavoriteEatery(eateryId: Int, eateryName: String) { viewModelScope.launch { - userRepository.addFavoriteEatery(eateryId, eateryName) + when (val result = userRepository.addFavoriteEatery(eateryId, eateryName)) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = + NetworkUiError.Failed(NetworkAction.AddFavoriteEatery, result.error) + } + } } } fun removeFavoriteEatery(eateryId: Int, eateryName: String) { viewModelScope.launch { - userRepository.removeFavoriteEatery(eateryId, eateryName) + when (val result = userRepository.removeFavoriteEatery(eateryId, eateryName)) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = + NetworkUiError.Failed(NetworkAction.RemoveFavoriteEatery, result.error) + } + } } } @@ -178,10 +206,19 @@ class HomeViewModel @Inject constructor( eateryRepository.pingEateries() } - fun updateFavoritesIfConfigured() { + fun updateFavoritesIfTokensConfigured() { if (userRepository.tokensConfiguredFlow.value) { viewModelScope.launch { - userRepository.updateFavorites() + when (val result = userRepository.updateFavorites()) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = + NetworkUiError.Failed(NetworkAction.GetFavorites, result.error) + } + } } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 56550e99..3bb412a6 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -3,11 +3,14 @@ package com.cornellappdev.android.eatery.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.AccountBalances +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.data.models.TransactionAccountType import com.cornellappdev.android.eatery.data.models.User import com.cornellappdev.android.eatery.data.models.toTransactionAccountType import com.cornellappdev.android.eatery.data.repositories.UserRepository +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -30,7 +33,7 @@ class LoginViewModel @Inject constructor( data class Login( val netID: String = "", val password: String = "", - val failureMessage: String? = null, + val failure: NetworkUiError? = null, val loading: Boolean = false ) : State() @@ -133,37 +136,52 @@ class LoginViewModel @Inject constructor( * Fetches user data given [sessionId] and updates the state and user preferences. */ private suspend fun linkGETAccount(sessionId: String) { - try { - userRepository.linkGETAccount(sessionId) - userRepository.setIsLoggedIn(true) - } catch (e: Exception) { - // todo error state - val currState = _state.value - if (currState is State.Login) { - val newState = currState.copy( - failureMessage = e.stackTraceToString(), - loading = false - ) - _state.value = newState + when (val result = userRepository.linkGETAccount(sessionId)) { + is Result.Success -> { + userRepository.setIsLoggedIn(true) + } + + is Result.Error -> { + val currState = _state.value + if (currState is State.Login) { + val newState = currState.copy( + failure = NetworkUiError.Failed(NetworkAction.LinkGetAccount, result.error), + loading = false + ) + _state.value = newState + } } } } suspend fun getFinancials() { - val financials = userRepository.getFinancials() - val newState = State.Account( - // todo null states should be handled - user = User( - brbBalance = financials.accounts?.brbBalance?.balance, - cityBucksBalance = financials.accounts?.cityBucksBalance?.balance, - laundryBalance = financials.accounts?.laundryBalance?.balance, - transactions = financials.transactions, -// mealSwipes = financials.accounts?. todo - mealswipes - ), - query = "", - accountFilter = TransactionAccountType.BRBS - ) - _state.value = newState + when (val result = userRepository.getFinancials()) { + is Result.Success -> { + val financials = result.data + val newState = State.Account( + user = User( + brbBalance = financials.accounts?.brbBalance?.balance, + cityBucksBalance = financials.accounts?.cityBucksBalance?.balance, + laundryBalance = financials.accounts?.laundryBalance?.balance, + transactions = financials.transactions, + // mealSwipes = financials.accounts?.mealSwipes + ), + query = "", + accountFilter = TransactionAccountType.BRBS + ) + _state.value = newState + } + + is Result.Error -> { + val currState = _state.value + if (currState is State.Login) { + val newState = currState.copy( + failure = NetworkUiError.Failed(NetworkAction.GetFinancials, result.error), + loading = false + ) + _state.value = newState + } + } + } } } - diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt index cd2e2321..7b4f0318 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/NearestViewModel.kt @@ -3,12 +3,17 @@ package com.cornellappdev.android.eatery.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -23,6 +28,13 @@ class NearestViewModel @Inject constructor( eateryRepository: EateryRepository, private val userRepository: UserRepository ) : ViewModel() { + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + /** * A flow emitting all the eateries the user has favorited. */ @@ -65,11 +77,24 @@ class NearestViewModel @Inject constructor( */ fun setFavorite(eateryId: Int, eateryName: String, favorite: Boolean) { viewModelScope.launch { - if (favorite) { + val result = if (favorite) { userRepository.addFavoriteEatery(eateryId, eateryName) } else { userRepository.removeFavoriteEatery(eateryId, eateryName) } + + when (result) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = NetworkUiError.Failed( + if (favorite) NetworkAction.AddFavoriteEatery else NetworkAction.RemoveFavoriteEatery, + result.error + ) + } + } } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt index 60f79c09..7fa79790 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt @@ -3,6 +3,7 @@ package com.cornellappdev.android.eatery.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository @@ -10,6 +11,8 @@ import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterData import com.cornellappdev.android.eatery.ui.components.general.updateFilters import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -28,6 +31,13 @@ class SearchViewModel @Inject constructor( ) : ViewModel() { private val _filtersFlow: MutableStateFlow> = MutableStateFlow(listOf()) + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + /** * A flow of filters applied to the screen. */ @@ -136,13 +146,31 @@ class SearchViewModel @Inject constructor( fun addFavorite(eateryId: Int, eateryName: String) { viewModelScope.launch { - userRepository.addFavoriteEatery(eateryId, eateryName) + when (val result = userRepository.addFavoriteEatery(eateryId, eateryName)) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = + NetworkUiError.Failed(NetworkAction.AddFavoriteEatery, result.error) + } + } } } fun removeFavorite(eateryId: Int, eateryName: String) { viewModelScope.launch { - userRepository.removeFavoriteEatery(eateryId, eateryName) + when (val result = userRepository.removeFavoriteEatery(eateryId, eateryName)) { + is Result.Success -> { + _error.value = null + } + + is Result.Error -> { + _error.value = + NetworkUiError.Failed(NetworkAction.RemoveFavoriteEatery, result.error) + } + } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SupportViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SupportViewModel.kt index 24e3622a..ff83c33d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SupportViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SupportViewModel.kt @@ -2,8 +2,14 @@ package com.cornellappdev.android.eatery.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.repositories.UserRepository +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -11,7 +17,31 @@ import javax.inject.Inject class SupportViewModel @Inject constructor( private val userRepository: UserRepository, ) : ViewModel() { + private val _reportState = MutableStateFlow(ReportState.Idle) + val reportState: StateFlow = _reportState.asStateFlow() + + sealed class ReportState { + object Idle : ReportState() + object Sending : ReportState() + object Success : ReportState() + data class Error(val error: NetworkUiError) : ReportState() + } + fun sendReport(issue: String, report: String) = viewModelScope.launch { - userRepository.sendReport(issue, report, null) + _reportState.value = ReportState.Sending + when (val result = userRepository.sendReport(issue, report, null)) { + is Result.Success -> { + _reportState.value = ReportState.Success + } + + is Result.Error -> { + _reportState.value = + ReportState.Error(NetworkUiError.Failed(NetworkAction.SendReport, result.error)) + } + } + } + + fun resetReportState() { + _reportState.value = ReportState.Idle } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkUiError.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkUiError.kt new file mode 100644 index 00000000..f09a1c67 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkUiError.kt @@ -0,0 +1,24 @@ +package com.cornellappdev.android.eatery.ui.viewmodels.state + +import com.cornellappdev.android.eatery.data.models.NetworkError + +enum class NetworkAction { + AddFavoriteEatery, + RemoveFavoriteEatery, + GetFavorites, + AddFavoriteItem, + RemoveFavoriteItem, + SendReport, + LinkGetAccount, + GetFinancials, +} + +/** + * Typed network error payload emitted by ViewModels. + */ +sealed interface NetworkUiError { + data class Failed( + val action: NetworkAction, + val reason: NetworkError, + ) : NetworkUiError +} From bd9063e1ad8a0a5cafd00e0781c053dcdc1ff8a9 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 17:13:32 -0500 Subject: [PATCH 070/126] Implement toast error handling --- .../components/general/NetworkErrorToast.kt | 31 +++++++++++++ .../eatery/ui/screens/EateryDetailScreen.kt | 6 +++ .../eatery/ui/screens/FavoritesScreen.kt | 7 +++ .../android/eatery/ui/screens/HomeScreen.kt | 9 +++- .../eatery/ui/screens/ProfileScreen.kt | 13 +++++- .../android/eatery/ui/screens/SearchScreen.kt | 6 +++ .../eatery/ui/viewmodels/HomeViewModel.kt | 2 +- .../eatery/ui/viewmodels/LoginViewModel.kt | 22 +++++---- .../viewmodels/state/NetworkErrorHandler.kt | 46 +++++++++++++++++++ .../ui/viewmodels/state/NetworkUiError.kt | 2 +- 10 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/NetworkErrorToast.kt create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkErrorHandler.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/NetworkErrorToast.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/NetworkErrorToast.kt new file mode 100644 index 00000000..daeb5c2e --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/NetworkErrorToast.kt @@ -0,0 +1,31 @@ +package com.cornellappdev.android.eatery.ui.components.general + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkErrorHandler +import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError + +/** + * Observes network errors and displays them via Toast. + * Call this from your screen composables. + * + * @param error The current error state from the ViewModel + * @param onErrorShown Callback invoked after showing the error (typically to clear it) + */ +@Composable +fun NetworkErrorToast( + error: NetworkUiError?, + onErrorShown: () -> Unit +) { + val context = LocalContext.current + + LaunchedEffect(error) { + if (error != null) { + NetworkErrorHandler.showError(context, error) + onErrorShown() + } + } +} + + diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt index c0fd49e8..dab6d34c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt @@ -93,6 +93,7 @@ import com.cornellappdev.android.eatery.ui.components.details.EateryHourBottomSh import com.cornellappdev.android.eatery.ui.components.details.EateryMealTabs import com.cornellappdev.android.eatery.ui.components.details.EateryMenusBottomSheet import com.cornellappdev.android.eatery.ui.components.details.PaymentWidgets +import com.cornellappdev.android.eatery.ui.components.general.NetworkErrorToast import com.cornellappdev.android.eatery.ui.components.general.PaymentMethodsAvailable import com.cornellappdev.android.eatery.ui.components.general.SearchBar import com.cornellappdev.android.eatery.ui.components.general.menuItems @@ -141,7 +142,12 @@ fun EateryDetailScreen( val coroutineScope = rememberCoroutineScope() val issue by remember { mutableStateOf(null) } val viewState = eateryDetailViewModel.eateryDetailViewState.collectAsState().value + val error by eateryDetailViewModel.error.collectAsState() + NetworkErrorToast( + error = error, + onErrorShown = eateryDetailViewModel::clearError + ) /** * The amount of days offset from the current weekday diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt index f4fc7753..a317f285 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt @@ -48,6 +48,7 @@ import com.cornellappdev.android.eatery.ui.components.details.ToggleRow import com.cornellappdev.android.eatery.ui.components.general.EateryCard import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterRow +import com.cornellappdev.android.eatery.ui.components.general.NetworkErrorToast import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.theme.GrayTwo @@ -68,7 +69,13 @@ fun FavoritesScreen( val favoritesScreenViewState = favoriteViewModel.favoritesScreenViewState.collectAsState().value var toggle by remember { mutableStateOf(true) } + val error by favoriteViewModel.error.collectAsState() + // TODO: replace with an actual error state + NetworkErrorToast( + error = error, + onErrorShown = favoriteViewModel::clearError + ) Column( modifier = Modifier diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 64096693..7f7e6414 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -82,6 +82,7 @@ import com.cornellappdev.android.eatery.ui.components.general.EateryCard import com.cornellappdev.android.eatery.ui.components.general.EateryCardStyle import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterRow +import com.cornellappdev.android.eatery.ui.components.general.NetworkErrorToast import com.cornellappdev.android.eatery.ui.components.general.NoEateryFound import com.cornellappdev.android.eatery.ui.components.general.PaymentMethodsBottomSheet import com.cornellappdev.android.eatery.ui.components.general.PermissionRequestDialog @@ -122,6 +123,13 @@ fun HomeScreen( val nearestEateries = homeViewModel.eateriesByDistance.collectAsState().value val eateriesApiResponse = homeViewModel.eateryFlow.collectAsState().value val filters = homeViewModel.filtersFlow.collectAsState().value + val error by homeViewModel.error.collectAsState() + + NetworkErrorToast( + error = error, + onErrorShown = homeViewModel::clearError + ) + val notificationPermissionState = rememberMultiplePermissionsState( permissions = listOf( @@ -792,4 +800,3 @@ private fun HomeMainHeader( object FirstTimeShown { var firstTimeShown = true } - diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index d33a6562..0f4af6d8 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -3,11 +3,13 @@ package com.cornellappdev.android.eatery.ui.screens import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.android.eatery.data.models.AccountBalances import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.data.models.TransactionAccountType +import com.cornellappdev.android.eatery.ui.components.general.NetworkErrorToast import com.cornellappdev.android.eatery.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel @@ -25,6 +27,16 @@ fun ProfileScreen( val state = loginViewModel.state.collectAsState().value val filteredTransactions = loginViewModel.filteredTransactionsFlow.collectAsState(initial = emptyList()).value + + // todo - replace toasts with actual error state + if (state is LoginViewModel.State.Login) { + val error by loginViewModel.error.collectAsState() + NetworkErrorToast( + error = error, + onErrorShown = loginViewModel::clearError + ) + } + ProfileScreenContent( isLoginState = state is LoginViewModel.State.Login, accountTypeBalance = state.getBalances(), @@ -85,7 +97,6 @@ private fun ProfileLoginScreenPreview() = EateryPreview { LoginViewModel.State.Login( netID = "aaa00", password = "myVeryLongPassword", - failure = null, loading = false ) ProfileScreenContent( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt index 8fe9026c..8f762d10 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt @@ -55,6 +55,7 @@ import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.ui.components.general.EateryCard import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterRow +import com.cornellappdev.android.eatery.ui.components.general.NetworkErrorToast import com.cornellappdev.android.eatery.ui.components.general.PaymentMethodsBottomSheet import com.cornellappdev.android.eatery.ui.components.general.SearchBar import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography @@ -89,7 +90,12 @@ fun SearchScreen( searchViewModel.recentSearches.collectAsState().value.reversed().take(10).distinct() val filters = searchViewModel.filtersFlow.collectAsState().value val searchResponse = searchViewModel.searchResultEateries.collectAsState().value + val error by searchViewModel.error.collectAsState() + NetworkErrorToast( + error = error, + onErrorShown = searchViewModel::clearError + ) // Automatically brings the search bar into focus when the view is composed LaunchedEffect(null) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 8b713bd3..2f30c1e3 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -216,7 +216,7 @@ class HomeViewModel @Inject constructor( is Result.Error -> { _error.value = - NetworkUiError.Failed(NetworkAction.GetFavorites, result.error) + NetworkUiError.Failed(NetworkAction.UpdateFavorites, result.error) } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 3bb412a6..72dfb543 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -33,7 +33,6 @@ class LoginViewModel @Inject constructor( data class Login( val netID: String = "", val password: String = "", - val failure: NetworkUiError? = null, val loading: Boolean = false ) : State() @@ -67,6 +66,13 @@ class LoginViewModel @Inject constructor( val state = _state.asStateFlow() + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + init { viewModelScope.launch { if (userRepository.isLoggedIn()) { @@ -139,15 +145,14 @@ class LoginViewModel @Inject constructor( when (val result = userRepository.linkGETAccount(sessionId)) { is Result.Success -> { userRepository.setIsLoggedIn(true) + _error.value = null } is Result.Error -> { + _error.value = NetworkUiError.Failed(NetworkAction.LinkGetAccount, result.error) val currState = _state.value if (currState is State.Login) { - val newState = currState.copy( - failure = NetworkUiError.Failed(NetworkAction.LinkGetAccount, result.error), - loading = false - ) + val newState = currState.copy(loading = false) _state.value = newState } } @@ -170,15 +175,14 @@ class LoginViewModel @Inject constructor( accountFilter = TransactionAccountType.BRBS ) _state.value = newState + _error.value = null } is Result.Error -> { + _error.value = NetworkUiError.Failed(NetworkAction.GetFinancials, result.error) val currState = _state.value if (currState is State.Login) { - val newState = currState.copy( - failure = NetworkUiError.Failed(NetworkAction.GetFinancials, result.error), - loading = false - ) + val newState = currState.copy(loading = false) _state.value = newState } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkErrorHandler.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkErrorHandler.kt new file mode 100644 index 00000000..0707287b --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkErrorHandler.kt @@ -0,0 +1,46 @@ +package com.cornellappdev.android.eatery.ui.viewmodels.state + +import android.content.Context +import android.widget.Toast +import com.cornellappdev.android.eatery.data.models.NetworkError + +/** + * Centralized handler for displaying network errors to users via Toast. + */ +object NetworkErrorHandler { + + /** + * Shows a toast message for the given network UI error. + */ + fun showError(context: Context, error: NetworkUiError?) { + if (error == null) return + + when (error) { + is NetworkUiError.Failed -> { + val message = buildErrorMessage(error.action, error.reason) + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + } + + /** + * Builds a user-friendly error message based on the action and network error reason. + */ + private fun buildErrorMessage(action: NetworkAction, reason: NetworkError): String { + val actionDescription = when (action) { + NetworkAction.AddFavoriteEatery -> "add favorite eatery" + NetworkAction.RemoveFavoriteEatery -> "remove favorite eatery" + NetworkAction.AddFavoriteItem -> "add favorite item" + NetworkAction.RemoveFavoriteItem -> "remove favorite item" + NetworkAction.UpdateFavorites -> "sync favorites" + NetworkAction.SendReport -> "send report" + NetworkAction.LinkGetAccount -> "link account" + NetworkAction.GetFinancials -> "load account information" + } + + return "Failed to $actionDescription: $reason" + } +} + + + diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkUiError.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkUiError.kt index f09a1c67..60b4682d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkUiError.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/state/NetworkUiError.kt @@ -5,7 +5,7 @@ import com.cornellappdev.android.eatery.data.models.NetworkError enum class NetworkAction { AddFavoriteEatery, RemoveFavoriteEatery, - GetFavorites, + UpdateFavorites, AddFavoriteItem, RemoveFavoriteItem, SendReport, From 7bf04e8e9277f1acd2df7327ba2fd260025a5820 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:23:49 +0000 Subject: [PATCH 071/126] Initial plan From 9f1854108ef4615a507efce68ea6505cd5d59e98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:24:56 +0000 Subject: [PATCH 072/126] Fix: only call getFinancials() when linkGETAccount succeeds Co-authored-by: caleb-bit <74190657+caleb-bit@users.noreply.github.com> --- .../android/eatery/ui/viewmodels/LoginViewModel.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 72dfb543..6fdbe2d6 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -133,19 +133,22 @@ class LoginViewModel @Inject constructor( fun onLoginWebViewSuccess(sessionId: String) { viewModelScope.launch { - linkGETAccount(sessionId) - getFinancials() + if (linkGETAccount(sessionId)) { + getFinancials() + } } } /** * Fetches user data given [sessionId] and updates the state and user preferences. + * Returns true if the account was linked successfully, false otherwise. */ - private suspend fun linkGETAccount(sessionId: String) { - when (val result = userRepository.linkGETAccount(sessionId)) { + private suspend fun linkGETAccount(sessionId: String): Boolean { + return when (val result = userRepository.linkGETAccount(sessionId)) { is Result.Success -> { userRepository.setIsLoggedIn(true) _error.value = null + true } is Result.Error -> { @@ -155,6 +158,7 @@ class LoginViewModel @Inject constructor( val newState = currState.copy(loading = false) _state.value = newState } + false } } } From cb7a73b733ac4b95c397ad257c2edb424d8f36ea Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 17:44:09 -0500 Subject: [PATCH 073/126] Remove redundant UUID creation --- .../com/cornellappdev/android/eatery/MainActivity.kt | 3 --- .../eatery/data/repositories/UserRepository.kt | 12 +----------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 9e71f64f..7cd94eca 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -128,9 +128,6 @@ class MainActivity : ComponentActivity() { } private suspend fun configureTokens() { - if (!userRepository.hasLaunchedBefore()) { - userRepository.registerDevice() - } userRepository.getTokens() } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 5a1a742a..45ca60bb 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -62,8 +62,6 @@ class UserRepository @Inject constructor( private val useLocalFavorites = BuildConfig.USE_LOCAL_FAVORITES - suspend fun hasLaunchedBefore(): Boolean = userPreferencesRepository.getDeviceId() != null - suspend fun getDeviceId(): String { val deviceId = userPreferencesRepository.getDeviceId() if (deviceId != null) return deviceId @@ -74,17 +72,9 @@ class UserRepository @Inject constructor( return uuid.toString() } - // called on first app launch - suspend fun registerDevice(): Result = safeRequest { - val deviceId = UUID.randomUUID() - userPreferencesRepository.setDeviceId(deviceId) - } - // called on app launch suspend fun getTokens(): Result = safeRequest { - val deviceId = - userPreferencesRepository.getDeviceId() - ?: throw Exception("Device not registered") + val deviceId = getDeviceId() val response = networkApi.verifyToken(DeviceId(deviceId)) val accessToken = response.accessToken val refreshToken = response.refreshToken From 42d0f9ff41ad07217322e315098fcd6de4230587 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 18:09:24 -0500 Subject: [PATCH 074/126] Make LoginViewModel correctly observe userRepository --- .../data/repositories/UserRepository.kt | 7 ++ .../eatery/ui/screens/ProfileScreen.kt | 2 +- .../eatery/ui/screens/SettingsScreen.kt | 65 +++++------ .../eatery/ui/viewmodels/LoginViewModel.kt | 101 ++++++++---------- 4 files changed, 80 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 45ca60bb..2dd0a7ef 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -234,6 +234,13 @@ class UserRepository @Inject constructor( sessionId = SessionID(userPreferencesRepository.getSessionId()) ) } + _loadedUser.value = User( + brbBalance = financials.accounts?.brbBalance?.balance, + cityBucksBalance = financials.accounts?.cityBucksBalance?.balance, + laundryBalance = financials.accounts?.laundryBalance?.balance, + transactions = financials.transactions, +// mealSwipes = financials.accounts?.mealSwipes + ) financials } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 0f4af6d8..6f756a5e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -24,7 +24,7 @@ fun ProfileScreen( webViewEnabled: Boolean, onBackClick: () -> Unit ) { - val state = loginViewModel.state.collectAsState().value + val state = loginViewModel.state.collectAsState(initial = LoginViewModel.State.Login()).value val filteredTransactions = loginViewModel.filteredTransactionsFlow.collectAsState(initial = emptyList()).value diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt index cf2c8567..83776efa 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,7 +50,6 @@ fun SettingsScreen( destinations: HashMap Unit> ) { // To sign out, setIsLoggedIn to false and transition back to profileView with autoLogin false - val state = loginViewModel.state.collectAsState().value val coroutineScope = rememberCoroutineScope() val modalBottomSheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, @@ -248,41 +246,34 @@ fun SettingsScreen( Spacer(modifier = Modifier.weight(1f)) - when (state) { - is LoginViewModel.State.Login -> { - } - - is LoginViewModel.State.Account -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 34.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - Button( - onClick = { - loginViewModel.onLogoutPressed(onDone = { - destinations[Routes.PROFILE]?.invoke() - }) - }, - shape = RoundedCornerShape(25.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = GrayZero, - contentColor = Color.Black - ) - ) { - Icon( - imageVector = Icons.Default.Logout, - contentDescription = Icons.Default.Logout.name, - ) - Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = "Log out", - style = EateryBlueTypography.button - ) - } - } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 34.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + loginViewModel.onLogoutPressed(onDone = { + destinations[Routes.PROFILE]?.invoke() + }) + }, + shape = RoundedCornerShape(25.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = GrayZero, + contentColor = Color.Black + ) + ) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = Icons.Default.Logout.name, + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = "Log out", + style = EateryBlueTypography.button + ) } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 6fdbe2d6..f43b2168 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -53,18 +53,40 @@ class LoginViewModel @Inject constructor( } } - private var _state = MutableStateFlow( - userRepository.loadedUser.value - ?.let { - State.Account( - user = it, - query = "", - accountFilter = TransactionAccountType.BRBS - ) - } ?: State.Login() - ) - - val state = _state.asStateFlow() + private val _queryFlow = MutableStateFlow("") + + fun setQuery(query: String) { + _queryFlow.value = query + } + + private val _accountTypeFilterFlow = MutableStateFlow(TransactionAccountType.BRBS) + + fun updateAccountFilter(newAccountType: TransactionAccountType) { + _accountTypeFilterFlow.value = newAccountType + } + + private val _loginLoadingFlow = MutableStateFlow(false) + + val state: Flow = combine( + userRepository.loadedUser, + _queryFlow, + _accountTypeFilterFlow, + _loginLoadingFlow + ) { loadedUser, query, accountFilter, loginLoading -> + if (loadedUser != null) { + State.Account( + user = loadedUser, + query = query, + accountFilter = accountFilter + ) + } else { + State.Login( + netID = "", + password = "", + loading = loginLoading + ) + } + } private val _error = MutableStateFlow(null) val error = _error.asStateFlow() @@ -81,23 +103,16 @@ class LoginViewModel @Inject constructor( } } - private val _queryFlow = MutableStateFlow("") - - fun setQuery(query: String) { - _queryFlow.value = query - } - - private val _accountTypeFilterFlow = MutableStateFlow(TransactionAccountType.BRBS) - - fun updateAccountFilter(newAccountType: TransactionAccountType) { - _accountTypeFilterFlow.value = newAccountType - } val filteredTransactionsFlow: Flow> = - combine(_state, _queryFlow, _accountTypeFilterFlow) { state, query, accountType -> - if (state !is State.Account) return@combine emptyList() + combine( + userRepository.loadedUser, + _queryFlow, + _accountTypeFilterFlow + ) { loadedUser, query, accountType -> + if (loadedUser == null) return@combine emptyList() val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") - state.user.transactions?.filter { transaction -> + loadedUser.transactions?.filter { transaction -> val matchesAccountType = transaction.accountType.toTransactionAccountType() == accountType val pastThirtyDays = LocalDateTime.parse( @@ -114,17 +129,10 @@ class LoginViewModel @Inject constructor( fun onLoginExited() = updateLoginLoadingState(false) private fun updateLoginLoadingState(isLoading: Boolean) { - val currState = _state.value - if (currState !is State.Login) return - - // Send the new loading Login state down - _state.value = currState.copy(loading = isLoading) - + _loginLoadingFlow.value = isLoading } fun onLogoutPressed(onDone: () -> Unit = {}) { - val newState = State.Login() - _state.value = newState viewModelScope.launch { userRepository.logout() onDone() @@ -153,11 +161,7 @@ class LoginViewModel @Inject constructor( is Result.Error -> { _error.value = NetworkUiError.Failed(NetworkAction.LinkGetAccount, result.error) - val currState = _state.value - if (currState is State.Login) { - val newState = currState.copy(loading = false) - _state.value = newState - } + updateLoginLoadingState(false) false } } @@ -166,29 +170,12 @@ class LoginViewModel @Inject constructor( suspend fun getFinancials() { when (val result = userRepository.getFinancials()) { is Result.Success -> { - val financials = result.data - val newState = State.Account( - user = User( - brbBalance = financials.accounts?.brbBalance?.balance, - cityBucksBalance = financials.accounts?.cityBucksBalance?.balance, - laundryBalance = financials.accounts?.laundryBalance?.balance, - transactions = financials.transactions, - // mealSwipes = financials.accounts?.mealSwipes - ), - query = "", - accountFilter = TransactionAccountType.BRBS - ) - _state.value = newState _error.value = null } is Result.Error -> { _error.value = NetworkUiError.Failed(NetworkAction.GetFinancials, result.error) - val currState = _state.value - if (currState is State.Login) { - val newState = currState.copy(loading = false) - _state.value = newState - } + updateLoginLoadingState(false) } } } From 38016cb9f4d7c0d1e9cc1472d4b22bddf7cfe84f Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 18:19:55 -0500 Subject: [PATCH 075/126] Create VM for settings --- .../eatery/ui/screens/SettingsScreen.kt | 6 ++--- .../eatery/ui/viewmodels/SettingsViewModel.kt | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SettingsViewModel.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt index 83776efa..813f5d37 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt @@ -40,13 +40,13 @@ import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.theme.GrayFive import com.cornellappdev.android.eatery.ui.theme.GrayZero -import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel +import com.cornellappdev.android.eatery.ui.viewmodels.SettingsViewModel import kotlinx.coroutines.launch @OptIn(ExperimentalMaterialApi::class) @Composable fun SettingsScreen( - loginViewModel: LoginViewModel = hiltViewModel(), + settingsViewModel: SettingsViewModel = hiltViewModel(), destinations: HashMap Unit> ) { // To sign out, setIsLoggedIn to false and transition back to profileView with autoLogin false @@ -255,7 +255,7 @@ fun SettingsScreen( ) { Button( onClick = { - loginViewModel.onLogoutPressed(onDone = { + settingsViewModel.onLogout(onDone = { destinations[Routes.PROFILE]?.invoke() }) }, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SettingsViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SettingsViewModel.kt new file mode 100644 index 00000000..209f3830 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SettingsViewModel.kt @@ -0,0 +1,22 @@ +package com.cornellappdev.android.eatery.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.cornellappdev.android.eatery.data.repositories.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val userRepository: UserRepository, +) : ViewModel() { + + fun onLogout(onDone: () -> Unit = {}) { + viewModelScope.launch { + userRepository.logout() + onDone() + } + } +} + From a4cd589b8f5c784f92b938b9db7a47ab9cece24d Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 18:27:10 -0500 Subject: [PATCH 076/126] Fix issue with menuItemsToEateries --- .../android/eatery/ui/viewmodels/FavoritesViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt index c1b57d3d..a5720266 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/FavoritesViewModel.kt @@ -105,7 +105,7 @@ class FavoritesViewModel @Inject constructor( val menuItemsToEateries: Map> = favoriteItems.associateWith { itemName -> - favoriteEateryObjects.filter { eatery -> + allEateries.filter { eatery -> val todayEvents = eatery.events?.filter { (it.endTimestamp ?: LocalDateTime.MAX) < LocalDateTime.now() .withHour(23) From fc0be18f5c7f543a86f935634d09231578b0008d Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 18:34:55 -0500 Subject: [PATCH 077/126] Use safer calls --- .../android/eatery/data/repositories/UserRepository.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 2dd0a7ef..14412b1a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -320,7 +320,8 @@ class UserRepository @Inject constructor( */ private suspend fun refreshTokens() { val deviceId = getDeviceId() - val refreshToken = userPreferencesRepository.getRefreshToken()!! + val refreshToken = userPreferencesRepository.getRefreshToken() + ?: throw IllegalStateException("Refresh token not available") val tokens = networkApi.refreshToken( RefreshRequest( deviceId = deviceId, @@ -345,7 +346,10 @@ class UserRepository @Inject constructor( * Gets access token with Bearer prefix assuming device has been registered */ private suspend fun getAccessToken(): String = - prependBearer(userPreferencesRepository.getAccessToken()!!) + prependBearer( + userPreferencesRepository.getAccessToken() + ?: throw IllegalStateException("Access token ont available") + ) private fun prependBearer(str: String) = "Bearer $str" } \ No newline at end of file From e8c2e9553a9aa09cc31ff25885e3e6ab39e88131 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 18:36:49 -0500 Subject: [PATCH 078/126] Fix incorrect documentation --- .../android/eatery/data/repositories/UserRepository.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 14412b1a..38517174 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -49,8 +49,7 @@ class UserRepository @Inject constructor( MutableStateFlow(emptyList()) /** - * A [StateFlow] emitting a map from menu items to whether they are favorited. - */ + * A [StateFlow] emitting a list of the names of the user's favorite menu items. */ val favoriteItemsFlow: StateFlow> = _favoriteItemsFlow.asStateFlow() private val _tokensConfiguredFlow: MutableStateFlow = MutableStateFlow(false) From dbbc137f26eafad5fd77b4a368c783456b681081 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 23:37:21 +0000 Subject: [PATCH 079/126] Initial plan From 23dfd559d536e5575be8f4baeaf738bf1eb3df46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 23:38:46 +0000 Subject: [PATCH 080/126] Fix memory leak: store and unregister flexible update listener in MainActivity Co-authored-by: caleb-bit <74190657+caleb-bit@users.noreply.github.com> --- .../cornellappdev/android/eatery/MainActivity.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 7cd94eca..0c514643 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -33,6 +33,7 @@ class MainActivity : ComponentActivity() { private lateinit var activityResultLauncher: ActivityResultLauncher private val appUpdateManager by lazy { AppUpdateManagerFactory.create(applicationContext) } + private var flexibleUpdateListener: InstallStateUpdatedListener? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -68,6 +69,14 @@ class MainActivity : ComponentActivity() { } } + override fun onDestroy() { + super.onDestroy() + flexibleUpdateListener?.let { + appUpdateManager.unregisterListener(it) + flexibleUpdateListener = null + } + } + override fun onResume() { super.onResume() @@ -110,12 +119,14 @@ class MainActivity : ComponentActivity() { // For normal priority updates, use flexible update else if (isFlexibleUpdateAllowed) { // Create a listener to track flexible update progress - val listener = InstallStateUpdatedListener { state -> + flexibleUpdateListener = InstallStateUpdatedListener { state -> if (state.installStatus() == InstallStatus.DOWNLOADED) { + flexibleUpdateListener?.let { appUpdateManager.unregisterListener(it) } + flexibleUpdateListener = null appUpdateManager.completeUpdate() } } - appUpdateManager.registerListener(listener) + flexibleUpdateListener?.let { appUpdateManager.registerListener(it) } appUpdateManager.startUpdateFlowForResult( appUpdateInfo, From dd3757328aefa1e2e1bd062cedb11c397dba997a Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 18:40:39 -0500 Subject: [PATCH 081/126] Remove redundant preview --- .../eatery/ui/screens/ProfileScreen.kt | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 6f756a5e..b2692aa9 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.android.eatery.data.models.AccountBalances import com.cornellappdev.android.eatery.data.models.Transaction @@ -13,7 +12,6 @@ import com.cornellappdev.android.eatery.ui.components.general.NetworkErrorToast import com.cornellappdev.android.eatery.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel -import com.cornellappdev.android.eatery.util.EateryPreview @OptIn(ExperimentalAnimationApi::class) @@ -89,34 +87,4 @@ private fun ProfileScreenContent( updateAccountFilter = updateAccountFilter ) } -} - -@Preview -@Composable -private fun ProfileLoginScreenPreview() = EateryPreview { - LoginViewModel.State.Login( - netID = "aaa00", - password = "myVeryLongPassword", - loading = false - ) - ProfileScreenContent( - isLoginState = false, - accountTypeBalance = AccountBalances( - brbBalance = 1234.56, - cityBucksBalance = 78.90, - laundryBalance = 12.34, - mealSwipes = 30 - ), - loading = false, - onLoginPressed = {}, - onSuccess = {}, - webViewEnabled = false, - onBackClick = {}, - onModalHidden = {}, - accountFilter = TransactionAccountType.BRBS, - onSettingsClicked = {}, - filteredTransactions = emptyList(), - onQueryChanged = {}, - updateAccountFilter = {}, - ) } \ No newline at end of file From 69ac9aa34c9775b4484bebea7747cb9945244072 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 18:45:21 -0500 Subject: [PATCH 082/126] Minor code improvements to LoginViewModel --- .../android/eatery/ui/viewmodels/LoginViewModel.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index f43b2168..51188dd5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -132,13 +132,6 @@ class LoginViewModel @Inject constructor( _loginLoadingFlow.value = isLoading } - fun onLogoutPressed(onDone: () -> Unit = {}) { - viewModelScope.launch { - userRepository.logout() - onDone() - } - } - fun onLoginWebViewSuccess(sessionId: String) { viewModelScope.launch { if (linkGETAccount(sessionId)) { @@ -167,7 +160,7 @@ class LoginViewModel @Inject constructor( } } - suspend fun getFinancials() { + private suspend fun getFinancials() { when (val result = userRepository.getFinancials()) { is Result.Success -> { _error.value = null From 17c11fed184736557fd564d29ac533a42ee733c6 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 18:56:09 -0500 Subject: [PATCH 083/126] Extract unregistering listener logic --- .../cornellappdev/android/eatery/MainActivity.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 0c514643..97e80702 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -71,10 +71,7 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() - flexibleUpdateListener?.let { - appUpdateManager.unregisterListener(it) - flexibleUpdateListener = null - } + unregisterFlexibleUpdateListener() } override fun onResume() { @@ -121,8 +118,7 @@ class MainActivity : ComponentActivity() { // Create a listener to track flexible update progress flexibleUpdateListener = InstallStateUpdatedListener { state -> if (state.installStatus() == InstallStatus.DOWNLOADED) { - flexibleUpdateListener?.let { appUpdateManager.unregisterListener(it) } - flexibleUpdateListener = null + unregisterFlexibleUpdateListener() appUpdateManager.completeUpdate() } } @@ -138,6 +134,11 @@ class MainActivity : ComponentActivity() { } } + private fun unregisterFlexibleUpdateListener() { + flexibleUpdateListener?.let(appUpdateManager::unregisterListener) + flexibleUpdateListener = null + } + private suspend fun configureTokens() { userRepository.getTokens() } From 7bc2278e98a3d8f615deae88633751e08303c672 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 7 Mar 2026 23:57:23 -0500 Subject: [PATCH 084/126] Remove runBlocking call --- .../java/com/cornellappdev/android/eatery/MainActivity.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 97e80702..705cdc35 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.WindowCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.navigation.NavigationSetup @@ -20,6 +21,7 @@ import com.google.android.play.core.install.model.AppUpdateType import com.google.android.play.core.install.model.InstallStatus import com.google.android.play.core.install.model.UpdateAvailability import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import javax.inject.Inject @@ -62,7 +64,7 @@ class MainActivity : ComponentActivity() { } lifecycle.addObserver(dataRefresher) checkForUpdateAvailability() - runBlocking { + lifecycleScope.launch { configureTokens() userRepository.updateFavorites() userRepository.markTokensAsConfigured() From 1987c22480a1f9598385d31f879192e052067000 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 00:01:27 -0500 Subject: [PATCH 085/126] Improve redundant computation --- .../android/eatery/ui/viewmodels/SearchViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt index 7fa79790..b7a7e8bc 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/SearchViewModel.kt @@ -76,13 +76,14 @@ class SearchViewModel @Inject constructor( is EateryApiResponse.Error -> EateryApiResponse.Error is EateryApiResponse.Pending -> EateryApiResponse.Pending is EateryApiResponse.Success -> { + val favoriteEateryIds = eateryApiResponse.data.filter { it.id != null } + .associate { it.id!! to (it.name in favorites) } EateryApiResponse.Success( eateryApiResponse.data.sortedBy { it.isClosed() }.filter { eatery -> Filter.passesSelectedFilters( searchScreenFilters, filters, FilterData( eatery = eatery, - favoriteEateryIds = eateryApiResponse.data.filter { it.id != null } - .associate { it.id!! to (it.name in favorites) } + favoriteEateryIds = favoriteEateryIds ) ) && eatery.passesSearch(searchQuery) }) From e5c5ce308b004bc3d44be6e062ad217506bf61a9 Mon Sep 17 00:00:00 2001 From: Caleb Shim <74190657+caleb-bit@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:02:53 -0500 Subject: [PATCH 086/126] Use null-safe access Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../eatery/ui/components/details/EateryMenusBottomSheet.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt index 611de026..c45fa185 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt @@ -99,7 +99,7 @@ fun EateryMenusBottomSheet( val selectedDayOfWeek = DayOfWeek.of(dayWeeks[currSelectedDay]) val mealTypes: List = eatery.getTypeMeal(selectedDayOfWeek) var selectedMealType by remember { - mutableStateOf(mealTypes[mealType].mealType) + mutableStateOf(mealTypes.getOrNull(mealType)?.mealType ?: "") } Card( From 892c76da1e667d1b716d4f6fec4f56245cf5d2ce Mon Sep 17 00:00:00 2001 From: Caleb Shim <74190657+caleb-bit@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:06:18 -0500 Subject: [PATCH 087/126] Remove debugging variable Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/cornellappdev/android/eatery/data/MoshiAdapters.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt index c959721a..7a605b67 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt @@ -72,8 +72,7 @@ class DateTimeAdapter { @FromJson fun fromJson(dateTime: String): LocalDateTime { - val x = LocalDateTime.ofInstant(Instant.parse(dateTime), ZoneId.systemDefault()) - return x + return LocalDateTime.ofInstant(Instant.parse(dateTime), ZoneId.systemDefault()) } } From 9cf0dc71b3b6fa80bdb0ad886d76e3ce91df1522 Mon Sep 17 00:00:00 2001 From: Caleb Shim <74190657+caleb-bit@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:07:28 -0500 Subject: [PATCH 088/126] Reuse function Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../android/eatery/ui/components/general/Filter.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt index 6de9ddfa..e9267b66 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt @@ -172,6 +172,5 @@ enum class MealFilter(val text: List, val endTimes: Float) { LATE_DINNER(listOf("LATE_NIGHT"), 22.5f); val displayName: String - get() = name.split('_') - .joinToString(" ") { it.lowercase().replaceFirstChar { char -> char.uppercase() } } + get() = name.toMealTypeDisplayName() } From c319240608a26d50a37dbe891bf102c52966c011 Mon Sep 17 00:00:00 2001 From: Caleb Shim <74190657+caleb-bit@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:08:34 -0500 Subject: [PATCH 089/126] Fix visibility issue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../android/eatery/data/repositories/UserRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 38517174..83ea23dc 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -29,7 +29,7 @@ import kotlin.random.Random @Singleton class UserRepository @Inject constructor( private val networkApi: NetworkApi, - val userPreferencesRepository: UserPreferencesRepository + private val userPreferencesRepository: UserPreferencesRepository ) { private val _loadedUser: MutableStateFlow = MutableStateFlow(null) From 8f72059da7b9dd542cb4992b140632b59a64a123 Mon Sep 17 00:00:00 2001 From: Caleb Shim <74190657+caleb-bit@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:09:19 -0500 Subject: [PATCH 090/126] Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../android/eatery/data/repositories/UserRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 83ea23dc..33572513 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -347,7 +347,7 @@ class UserRepository @Inject constructor( private suspend fun getAccessToken(): String = prependBearer( userPreferencesRepository.getAccessToken() - ?: throw IllegalStateException("Access token ont available") + ?: throw IllegalStateException("Access token not available") ) private fun prependBearer(str: String) = "Bearer $str" From 84717d4b7c1194661ac157622e8e6c17cc2e6e87 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 00:15:16 -0500 Subject: [PATCH 091/126] Use setPref helper --- .../repositories/UserPreferencesRepository.kt | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index cc262926..5311798c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -24,32 +24,20 @@ class UserPreferencesRepository @Inject constructor( prefs.recentSearchesList }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, listOf()) - suspend fun setHasOnboarded(hasOnboarded: Boolean) { - userPreferencesStore.updateData { currentPreferences -> - currentPreferences.toBuilder().setHasOnboarded(hasOnboarded).build() - } + suspend fun setHasOnboarded(hasOnboarded: Boolean) = setPref { + setHasOnboarded(hasOnboarded) } - suspend fun setNotificationFlowCompleted(value: Boolean) { - userPreferencesStore.updateData { currentPreferences -> - currentPreferences.toBuilder().setNotificationFlowCompleted(value).build() - } + suspend fun setNotificationFlowCompleted(value: Boolean) = setPref { + setNotificationFlowCompleted(value) } - suspend fun setAnalyticsDisabled(analyticsDisabled: Boolean) { - userPreferencesStore.updateData { currentPreferences -> - currentPreferences.toBuilder() - .setAnalyticsDisabled(analyticsDisabled) - .build() - } + suspend fun setAnalyticsDisabled(analyticsDisabled: Boolean) = setPref { + setAnalyticsDisabled(analyticsDisabled) } - suspend fun addRecentSearch(eateryId: Int) { - userPreferencesStore.updateData { currentPreferences -> - currentPreferences.toBuilder() - .addRecentSearches(eateryId) - .build() - } + suspend fun addRecentSearch(eateryId: Int) = setPref { + addRecentSearches(eateryId) } suspend fun setFavoriteEateryName(eateryName: String, isFavorite: Boolean) { From fb6cf94f745289595daca6ffdc16527bbf3a3d6a Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 00:19:15 -0500 Subject: [PATCH 092/126] Fix import issue --- .../cornellappdev/android/eatery/ui/components/general/Filter.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt index e9267b66..48b2942d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/Filter.kt @@ -2,6 +2,7 @@ package com.cornellappdev.android.eatery.ui.components.general import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.ui.components.general.Filter.FromEateryFilter +import com.cornellappdev.android.eatery.util.toMealTypeDisplayName import java.time.LocalDateTime data class FilterData( From 4629308a647e9266fcd9cebffa1419f4d10b97a6 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 00:25:52 -0500 Subject: [PATCH 093/126] Simplify function --- .../data/repositories/UserPreferencesRepository.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 5311798c..f17eecc2 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -86,16 +86,13 @@ class UserPreferencesRepository @Inject constructor( setPref { setDeviceId(deviceId.toString()) } } - private fun getStringPref(s: String?): String? { - return if (s.isNullOrEmpty()) null else s - } - + private fun String?.nullIfEmpty(): String? = if (this.isNullOrEmpty()) null else this suspend fun getDeviceId(): String? { - return getStringPref(userPreferencesFlow.firstOrNull()?.deviceId) + return userPreferencesFlow.firstOrNull()?.deviceId.nullIfEmpty() } suspend fun getAccessToken(): String? { - return getStringPref(userPreferencesFlow.firstOrNull()?.accessToken) + return userPreferencesFlow.firstOrNull()?.accessToken.nullIfEmpty() } suspend fun setAccessToken(accessToken: String) { @@ -103,7 +100,7 @@ class UserPreferencesRepository @Inject constructor( } suspend fun getRefreshToken(): String? { - return getStringPref(userPreferencesFlow.firstOrNull()?.refreshToken) + return userPreferencesFlow.firstOrNull()?.refreshToken.nullIfEmpty() } suspend fun setRefreshToken(refreshToken: String) { From 3115631c3de939ac7cb9cc127fa388b60a408058 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 00:27:03 -0500 Subject: [PATCH 094/126] Import UUID --- .../eatery/data/repositories/UserPreferencesRepository.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index f17eecc2..009fcf81 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -82,7 +83,7 @@ class UserPreferencesRepository @Inject constructor( } } - suspend fun setDeviceId(deviceId: java.util.UUID) { + suspend fun setDeviceId(deviceId: UUID) { setPref { setDeviceId(deviceId.toString()) } } From eed01f9b7af7213f68157f2f39424203f81f97e3 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 00:54:14 -0500 Subject: [PATCH 095/126] Use flows in UserPreferencesRepository --- .../repositories/UserPreferencesRepository.kt | 51 ++++++------------- .../data/repositories/UserRepository.kt | 21 ++++---- .../android/eatery/ui/screens/HomeScreen.kt | 3 +- .../eatery/ui/viewmodels/HomeViewModel.kt | 9 ++-- .../eatery/ui/viewmodels/PrivacyViewModel.kt | 19 +++---- 5 files changed, 40 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 009fcf81..ab72e1b5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -7,8 +7,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import java.util.UUID @@ -21,6 +19,21 @@ class UserPreferencesRepository @Inject constructor( ) { private val userPreferencesFlow: Flow = userPreferencesStore.data + val hasOnboardedFlow: Flow = userPreferencesFlow.map { it.hasOnboarded } + val notificationFlowCompletedFlow: Flow = + userPreferencesFlow.map { it.notificationFlowCompleted } + val analyticsDisabledFlow: Flow = userPreferencesFlow.map { it.analyticsDisabled } + val deviceIdFlow: Flow = userPreferencesFlow.map { it.deviceId.nullIfEmpty() } + val accessTokenFlow: Flow = userPreferencesFlow.map { it.accessToken.nullIfEmpty() } + val refreshTokenFlow: Flow = userPreferencesFlow.map { it.refreshToken.nullIfEmpty() } + val isLoggedInFlow: Flow = userPreferencesFlow.map { it.isLoggedIn } + val pinFlow: Flow = userPreferencesFlow.map { it.pin } + val sessionIdFlow: Flow = userPreferencesFlow.map { it.sessionId } + val favoriteEateryNamesFlow: Flow> = + userPreferencesFlow.map { it.favoriteEateryNamesList } + val favoriteItemNamesFlow: Flow> = + userPreferencesFlow.map { it.itemFavoritesMap.keys.toList() } + val recentSearchesFlow: StateFlow> = userPreferencesFlow.map { prefs -> prefs.recentSearchesList }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, listOf()) @@ -66,15 +79,6 @@ class UserPreferencesRepository @Inject constructor( } } - suspend fun getHasOnboarded(): Boolean = - userPreferencesFlow.first().hasOnboarded - - suspend fun getNotificationFlowCompleted(): Boolean = - userPreferencesFlow.first().notificationFlowCompleted - - suspend fun getAnalyticsDisabled(): Boolean = - userPreferencesFlow.first().analyticsDisabled - private suspend fun setPref(setter: UserPreferences.Builder.() -> UserPreferences.Builder) { userPreferencesStore.updateData { currentPreferences -> currentPreferences.toBuilder() @@ -88,49 +92,24 @@ class UserPreferencesRepository @Inject constructor( } private fun String?.nullIfEmpty(): String? = if (this.isNullOrEmpty()) null else this - suspend fun getDeviceId(): String? { - return userPreferencesFlow.firstOrNull()?.deviceId.nullIfEmpty() - } - - suspend fun getAccessToken(): String? { - return userPreferencesFlow.firstOrNull()?.accessToken.nullIfEmpty() - } suspend fun setAccessToken(accessToken: String) { setPref { setAccessToken(accessToken) } } - suspend fun getRefreshToken(): String? { - return userPreferencesFlow.firstOrNull()?.refreshToken.nullIfEmpty() - } - suspend fun setRefreshToken(refreshToken: String) { setPref { setRefreshToken(refreshToken) } } - suspend fun getIsLoggedIn(): Boolean { - val flow = userPreferencesFlow.firstOrNull() - return flow?.isLoggedIn ?: false - } - suspend fun setIsLoggedIn(loggedIn: Boolean) = setPref { setIsLoggedIn(loggedIn) } - suspend fun getPin(): Int = userPreferencesFlow.first().pin - suspend fun setPin(pin: Int) { setPref { setPin(pin) } } - suspend fun getSessionId(): String = userPreferencesFlow.first().sessionId suspend fun setSessionId(sessionId: String) { setPref { setSessionId(sessionId) } } - - suspend fun getFavoriteEateryNames(): List = - userPreferencesFlow.firstOrNull()?.favoriteEateryNamesList ?: emptyList() - - suspend fun getFavoriteItemNames(): List = - userPreferencesFlow.firstOrNull()?.itemFavoritesMap?.keys?.toList() ?: emptyList() } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 33572513..335862d3 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -17,6 +17,7 @@ import com.cornellappdev.android.eatery.data.models.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import retrofit2.HttpException import java.io.IOException @@ -62,7 +63,7 @@ class UserRepository @Inject constructor( private val useLocalFavorites = BuildConfig.USE_LOCAL_FAVORITES suspend fun getDeviceId(): String { - val deviceId = userPreferencesRepository.getDeviceId() + val deviceId = userPreferencesRepository.deviceIdFlow.first() if (deviceId != null) return deviceId // first launch @@ -95,8 +96,8 @@ class UserRepository @Inject constructor( suspend fun updateFavorites(): Result { if (useLocalFavorites) { - _favoritesEateriesFlow.value = userPreferencesRepository.getFavoriteEateryNames() - _favoriteItemsFlow.value = userPreferencesRepository.getFavoriteItemNames() + _favoritesEateriesFlow.value = userPreferencesRepository.favoriteEateryNamesFlow.first() + _favoriteItemsFlow.value = userPreferencesRepository.favoriteItemNamesFlow.first() return Result.Success(Unit) } @@ -223,14 +224,14 @@ class UserRepository @Inject constructor( try { financials = networkApi.getFinancials( accessToken = getAccessToken(), - sessionId = SessionID(userPreferencesRepository.getSessionId()) + sessionId = SessionID(userPreferencesRepository.sessionIdFlow.first()) ) } catch (_: Exception) { - val pin = userPreferencesRepository.getPin() + val pin = userPreferencesRepository.pinFlow.first() refreshLogin(pin = pin) financials = networkApi.getFinancials( accessToken = getAccessToken(), - sessionId = SessionID(userPreferencesRepository.getSessionId()) + sessionId = SessionID(userPreferencesRepository.sessionIdFlow.first()) ) } _loadedUser.value = User( @@ -246,7 +247,7 @@ class UserRepository @Inject constructor( suspend fun setIsLoggedIn(isLoggedIn: Boolean) = userPreferencesRepository.setIsLoggedIn(isLoggedIn) - suspend fun isLoggedIn(): Boolean = userPreferencesRepository.getIsLoggedIn() + suspend fun isLoggedIn(): Boolean = userPreferencesRepository.isLoggedInFlow.first() /** * Refreshes GET sessionID. @@ -269,7 +270,7 @@ class UserRepository @Inject constructor( userPreferencesRepository.setIsLoggedIn(false) } - suspend fun hasOnboarded(): Boolean = userPreferencesRepository.getHasOnboarded() + suspend fun hasOnboarded(): Boolean = userPreferencesRepository.hasOnboardedFlow.first() /** * Converts exceptions into appropriate [NetworkError] types. @@ -319,7 +320,7 @@ class UserRepository @Inject constructor( */ private suspend fun refreshTokens() { val deviceId = getDeviceId() - val refreshToken = userPreferencesRepository.getRefreshToken() + val refreshToken = userPreferencesRepository.refreshTokenFlow.first() ?: throw IllegalStateException("Refresh token not available") val tokens = networkApi.refreshToken( RefreshRequest( @@ -346,7 +347,7 @@ class UserRepository @Inject constructor( */ private suspend fun getAccessToken(): String = prependBearer( - userPreferencesRepository.getAccessToken() + userPreferencesRepository.accessTokenFlow.first() ?: throw IllegalStateException("Access token not available") ) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 7f7e6414..1e6eb511 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -124,6 +124,7 @@ fun HomeScreen( val eateriesApiResponse = homeViewModel.eateryFlow.collectAsState().value val filters = homeViewModel.filtersFlow.collectAsState().value val error by homeViewModel.error.collectAsState() + val notificationFlowCompleted by homeViewModel.notificationFlowCompleted.collectAsState() NetworkErrorToast( error = error, @@ -261,7 +262,7 @@ fun HomeScreen( if (FirstTimeShown.firstTimeShown) { PermissionRequestDialog( showBottomBar = showBottomBar, - notificationFlowStatus = homeViewModel.getNotificationFlowCompleted(), + notificationFlowStatus = notificationFlowCompleted, updateNotificationFlowStatus = { homeViewModel.setNotificationFlowCompleted(it) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 2f30c1e3..89226693 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import javax.inject.Inject @HiltViewModel @@ -135,6 +134,10 @@ class HomeViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) + val notificationFlowCompleted: StateFlow = + userPreferencesRepository.notificationFlowCompletedFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + var bigPopUp by mutableStateOf(false) fun setPopUp(bool: Boolean) { @@ -194,10 +197,6 @@ class HomeViewModel @Inject constructor( } } - fun getNotificationFlowCompleted() = runBlocking { - return@runBlocking userPreferencesRepository.getNotificationFlowCompleted() - } - fun setNotificationFlowCompleted(value: Boolean) = viewModelScope.launch { userPreferencesRepository.setNotificationFlowCompleted(value) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/PrivacyViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/PrivacyViewModel.kt index 2e823b52..0ff78c42 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/PrivacyViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/PrivacyViewModel.kt @@ -1,12 +1,12 @@ package com.cornellappdev.android.eatery.ui.viewmodels -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -15,17 +15,14 @@ class PrivacyViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, ) : ViewModel() { - var analyticsDisabled: Boolean by mutableStateOf(false) - private set + val analyticsDisabledFlow: StateFlow = + userPreferencesRepository.analyticsDisabledFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, false) - init { - viewModelScope.launch { - analyticsDisabled = userPreferencesRepository.getAnalyticsDisabled() - } - } + val analyticsDisabled: Boolean + get() = analyticsDisabledFlow.value fun setAnalyticsDisabled(analyticsDisabled: Boolean) = viewModelScope.launch { userPreferencesRepository.setAnalyticsDisabled(analyticsDisabled) - this@PrivacyViewModel.analyticsDisabled = analyticsDisabled } } From b78ecefcb006eb7a3fbabc8adb14628860c694fb Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 00:56:40 -0500 Subject: [PATCH 096/126] Fix name typo --- .../eatery/data/repositories/UserRepository.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 335862d3..0ce33767 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -39,13 +39,13 @@ class UserRepository @Inject constructor( */ val loadedUser: StateFlow = _loadedUser.asStateFlow() - private val _favoritesEateriesFlow: MutableStateFlow> = + private val _favoriteEateriesFlow: MutableStateFlow> = MutableStateFlow(emptyList()) /** * A [StateFlow] emitting a list of the names of the user's favorite eateries. */ - val favoriteEateriesFlow: StateFlow> = _favoritesEateriesFlow.asStateFlow() + val favoriteEateriesFlow: StateFlow> = _favoriteEateriesFlow.asStateFlow() private val _favoriteItemsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) @@ -96,7 +96,7 @@ class UserRepository @Inject constructor( suspend fun updateFavorites(): Result { if (useLocalFavorites) { - _favoritesEateriesFlow.value = userPreferencesRepository.favoriteEateryNamesFlow.first() + _favoriteEateriesFlow.value = userPreferencesRepository.favoriteEateryNamesFlow.first() _favoriteItemsFlow.value = userPreferencesRepository.favoriteItemNamesFlow.first() return Result.Success(Unit) } @@ -104,7 +104,7 @@ class UserRepository @Inject constructor( return tryRequestWithResult { val accessPhrase = getAccessToken() val matches = networkApi.getFavoriteMatches(accessToken = accessPhrase) - _favoritesEateriesFlow.value = matches.mapNotNull { it.eateryName } + _favoriteEateriesFlow.value = matches.mapNotNull { it.eateryName } _favoriteItemsFlow.value = run { val items: MutableList = mutableListOf() matches.forEach { (_, eateryItems) -> @@ -170,7 +170,7 @@ class UserRepository @Inject constructor( suspend fun addFavoriteEatery(id: Int, eateryName: String): Result { if (useLocalFavorites) { userPreferencesRepository.setFavoriteEateryName(eateryName, true) - _favoritesEateriesFlow.update { currentEateries -> + _favoriteEateriesFlow.update { currentEateries -> if (eateryName !in currentEateries) currentEateries + eateryName else currentEateries } return Result.Success(Unit) @@ -181,7 +181,7 @@ class UserRepository @Inject constructor( accessToken = getAccessToken(), eatery = FavoriteEatery(id), ) - _favoritesEateriesFlow.update { currentEateries -> + _favoriteEateriesFlow.update { currentEateries -> if (eateryName !in currentEateries) currentEateries + eateryName else currentEateries } } @@ -190,7 +190,7 @@ class UserRepository @Inject constructor( suspend fun removeFavoriteEatery(id: Int, eateryName: String): Result { if (useLocalFavorites) { userPreferencesRepository.setFavoriteEateryName(eateryName, false) - _favoritesEateriesFlow.update { currentEateries -> + _favoriteEateriesFlow.update { currentEateries -> currentEateries.filter { it != eateryName } } return Result.Success(Unit) @@ -201,7 +201,7 @@ class UserRepository @Inject constructor( accessToken = getAccessToken(), eatery = FavoriteEatery(id) ) - _favoritesEateriesFlow.update { currentEateries -> + _favoriteEateriesFlow.update { currentEateries -> currentEateries.filter { it != eateryName } } } From fe03d2c5f6374058300148451b861b658f05c402 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 01:03:14 -0500 Subject: [PATCH 097/126] Simplify collection transformation --- .../android/eatery/data/repositories/UserRepository.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 0ce33767..a2212a43 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -106,12 +106,8 @@ class UserRepository @Inject constructor( val matches = networkApi.getFavoriteMatches(accessToken = accessPhrase) _favoriteEateriesFlow.value = matches.mapNotNull { it.eateryName } _favoriteItemsFlow.value = run { - val items: MutableList = mutableListOf() - matches.forEach { (_, eateryItems) -> - if (eateryItems != null) { - items.addAll(eateryItems.mapNotNull { it.name }) - } - } + val items: List = + matches.flatMap { it.items.orEmpty() }.mapNotNull { it.name } items.toList() } } From 70690dd1f8cdd99f7a84de5e7b5e032fbd4e6a27 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 01:15:14 -0500 Subject: [PATCH 098/126] Clean up EateryMenusBottomSheet.kt --- .../details/EateryMenusBottomSheet.kt | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt index c45fa185..b89f225a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt @@ -64,30 +64,12 @@ fun EateryMenusBottomSheet( val zoneId: ZoneId? = ZoneId.of("America/New_York") val today = LocalDate.now(zoneId) val currentDay by remember { mutableStateOf(today) } - val dayWeek: Int = currentDay.dayOfWeek.value - val dayNum: Int = currentDay.dayOfMonth - val dayNames = mutableListOf() - val dayWeeks = mutableListOf() - val days = mutableListOf() - dayWeeks.add(dayWeek) - days.add(dayNum) - for (i in 1 until 7) { - dayWeeks.add(currentDay.plusDays(i.toLong()).dayOfWeek.value) - days.add(currentDay.plusDays(i.toLong()).dayOfMonth) - } - dayWeeks.forEach { - var dayName = "" - when (it) { - 1 -> dayName = "Mon" - 2 -> dayName = "Tue" - 3 -> dayName = "Wed" - 4 -> dayName = "Thu" - 5 -> dayName = "Fri" - 6 -> dayName = "Sat" - 7 -> dayName = "Sun" - } - dayNames.add(dayName) - } + + val weekDates = (0..6).map { currentDay.plusDays(it.toLong()) } + val dayWeeks = weekDates.map { it.dayOfWeek.value } + val days = weekDates.map { it.dayOfMonth } + val dayNames = weekDates.map { it.dayOfWeek.toReadableShortName() } + var selectedDay by remember { mutableStateOf(weekDayIndex) } var currSelectedDay by remember { mutableStateOf(selectedDay) } @@ -126,7 +108,6 @@ fun EateryMenusBottomSheet( } IconButton( onClick = { -// openUpcoming = false onDismiss() }, modifier = Modifier @@ -156,9 +137,7 @@ fun EateryMenusBottomSheet( eateryDetail = true ) -// Spacer(modifier = Modifier.height(12.dp)) - - //display of possible meal descriptions (none for cafes) + //display of possible meal descriptions (none for cafés) Column( modifier = Modifier .padding(top = 12.dp, bottom = 12.dp) @@ -268,8 +247,6 @@ fun EateryMenusBottomSheet( onDismiss() }) } - } - } } From 4af08a15412ade09ec177a35feae0c22b7f26318 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 01:30:46 -0500 Subject: [PATCH 099/126] Add preview for EateryDetailScreen --- .../eatery/ui/screens/EateryDetailScreen.kt | 133 ++++++++++++++---- 1 file changed, 102 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt index dab6d34c..75bb608e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt @@ -77,12 +77,14 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.Event +import com.cornellappdev.android.eatery.data.models.MenuItem import com.cornellappdev.android.eatery.data.repositories.CoilRepository import com.cornellappdev.android.eatery.ui.components.comparemenus.CompareMenusBotSheet import com.cornellappdev.android.eatery.ui.components.comparemenus.CompareMenusFAB @@ -93,6 +95,8 @@ import com.cornellappdev.android.eatery.ui.components.details.EateryHourBottomSh import com.cornellappdev.android.eatery.ui.components.details.EateryMealTabs import com.cornellappdev.android.eatery.ui.components.details.EateryMenusBottomSheet import com.cornellappdev.android.eatery.ui.components.details.PaymentWidgets +import com.cornellappdev.android.eatery.ui.components.general.MenuCategoryViewState +import com.cornellappdev.android.eatery.ui.components.general.MenuItemViewState import com.cornellappdev.android.eatery.ui.components.general.NetworkErrorToast import com.cornellappdev.android.eatery.ui.components.general.PaymentMethodsAvailable import com.cornellappdev.android.eatery.ui.components.general.SearchBar @@ -113,7 +117,10 @@ import com.cornellappdev.android.eatery.ui.theme.Yellow import com.cornellappdev.android.eatery.ui.theme.colorInterp import com.cornellappdev.android.eatery.ui.viewmodels.EateryDetailViewModel import com.cornellappdev.android.eatery.ui.viewmodels.EateryDetailViewState +import com.cornellappdev.android.eatery.ui.viewmodels.MealViewState import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse +import com.cornellappdev.android.eatery.util.EateryPreview +import com.cornellappdev.android.eatery.util.PreviewData import com.cornellappdev.android.eatery.util.fromOffsetToDayOfWeek import com.cornellappdev.android.eatery.util.toMealTypeDisplayName import com.cornellappdev.android.eatery.util.toReadableFullName @@ -121,6 +128,7 @@ import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -129,6 +137,43 @@ import java.time.format.DateTimeFormatter fun EateryDetailScreen( eateryDetailViewModel: EateryDetailViewModel = hiltViewModel(), onCompareMenusClick: (selectedEateriesIds: List) -> Unit, +) { + val viewState = eateryDetailViewModel.eateryDetailViewState.collectAsState().value + val error by eateryDetailViewModel.error.collectAsState() + val filterText by eateryDetailViewModel.searchQueryFlow.collectAsState() + + NetworkErrorToast( + error = error, + onErrorShown = eateryDetailViewModel::clearError + ) + + EateryDetailScreenContent( + viewState = viewState, + filterText = filterText, + onCompareMenusClick = onCompareMenusClick, + onToggleFavorite = eateryDetailViewModel::toggleFavorite, + onSendReport = eateryDetailViewModel::sendReport, + onSelectEvent = eateryDetailViewModel::selectEvent, + onSetSelectedWeekdayIndex = eateryDetailViewModel::setSelectedWeekdayIndex, + onResetSelectedEvent = eateryDetailViewModel::resetSelectedEvent, + onSearchQueryChange = eateryDetailViewModel::setSearchQuery, + onToggleFavoriteMenuItem = eateryDetailViewModel::toggleFavoriteMenuItem, + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun EateryDetailScreenContent( + viewState: EateryDetailViewState, + filterText: String, + onCompareMenusClick: (selectedEateriesIds: List) -> Unit, + onToggleFavorite: () -> Unit, + onSendReport: (issue: String, report: String, eateryId: Int?) -> Unit, + onSelectEvent: (eatery: Eatery, dayIndex: Int, mealDescription: String) -> Unit, + onSetSelectedWeekdayIndex: (Int) -> Unit, + onResetSelectedEvent: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onToggleFavoriteMenuItem: (String) -> Unit, ) { val shimmer = rememberShimmer(ShimmerBounds.View) val context = LocalContext.current @@ -141,20 +186,6 @@ fun EateryDetailScreen( val paymentMethods = remember { mutableStateListOf() } val coroutineScope = rememberCoroutineScope() val issue by remember { mutableStateOf(null) } - val viewState = eateryDetailViewModel.eateryDetailViewState.collectAsState().value - val error by eateryDetailViewModel.error.collectAsState() - - NetworkErrorToast( - error = error, - onErrorShown = eateryDetailViewModel::clearError - ) - - /** - * The amount of days offset from the current weekday - */ - - // The filter text typed in. - val filterText by eateryDetailViewModel.searchQueryFlow.collectAsState() var showFAB by remember { mutableStateOf(true) @@ -242,11 +273,7 @@ fun EateryDetailScreen( issue = issue, eateryid = it, sendReport = { issue, report, eateryId -> - eateryDetailViewModel.sendReport( - issue, - report, - eateryId - ) + onSendReport(issue, report, eateryId) }) { coroutineScope.launch { modalBottomSheetState.hide() @@ -279,16 +306,16 @@ fun EateryDetailScreen( }, eatery = eatery, onShowMenuClick = { dayIndex, mealDescription, _ -> - eateryDetailViewModel.selectEvent( + onSelectEvent( eatery, dayIndex, mealDescription ) - eateryDetailViewModel.setSelectedWeekdayIndex(dayIndex) + onSetSelectedWeekdayIndex(dayIndex) }, onResetClick = { - eateryDetailViewModel.setSelectedWeekdayIndex(0) - eateryDetailViewModel.resetSelectedEvent() + onSetSelectedWeekdayIndex(0) + onResetSelectedEvent() }, mealType = viewState.mealTypeIndex ) @@ -406,7 +433,7 @@ fun EateryDetailScreen( Button( - onClick = { eateryDetailViewModel.toggleFavorite() }, + onClick = onToggleFavorite, modifier = Modifier .align(Alignment.TopEnd) .padding(top = 40.dp, end = 16.dp) @@ -631,14 +658,12 @@ fun EateryDetailScreen( SearchBar( searchText = filterText, onSearchTextChange = { - eateryDetailViewModel.setSearchQuery( - it - ) + onSearchQueryChange(it) }, placeholderText = "Search the menu...", modifier = Modifier.padding(horizontal = 16.dp), onCancelClicked = { - eateryDetailViewModel.setSearchQuery("") + onSearchQueryChange("") }) Spacer( modifier = Modifier @@ -661,7 +686,7 @@ fun EateryDetailScreen( EateryMealTabs( meals = mealTypes, onSelectMeal = { selectedMeal -> - eateryDetailViewModel.selectEvent( + onSelectEvent( eatery, viewState.weekdayIndex, mealTypes[selectedMeal] @@ -680,7 +705,7 @@ fun EateryDetailScreen( } ) }, onFavoriteClick = { - eateryDetailViewModel.toggleFavoriteMenuItem(it) + onToggleFavoriteMenuItem(it) }) item { @@ -775,7 +800,7 @@ fun EateryDetailScreen( EateryHeader( eatery = eatery, isFavorite = viewState.isFavorite, - onFavoriteClick = eateryDetailViewModel::toggleFavorite + onFavoriteClick = onToggleFavorite ) EateryDetailsStickyHeader( nextEvent.toEvent(), @@ -898,3 +923,49 @@ fun EateryHeader(eatery: Eatery, isFavorite: Boolean, onFavoriteClick: () -> Uni } } } + +@Preview(showBackground = true) +@Composable +private fun EateryDetailScreenPreview() = EateryPreview { + val now = LocalDateTime.now() + val mealViewState = MealViewState( + startTime = now.withHour(11).withMinute(0), + endTime = now.withHour(14).withMinute(0), + menu = listOf( + MenuCategoryViewState( + category = "Featured", + items = listOf( + MenuItemViewState( + isFavorite = true, + item = MenuItem(name = "Pesto Pasta") + ), + MenuItemViewState( + isFavorite = false, + item = MenuItem(name = "Tomato Soup") + ) + ) + ) + ), + description = "Lunch" + ) + + EateryDetailScreenContent( + viewState = EateryDetailViewState.Loaded( + mealToShow = mealViewState, + eatery = PreviewData.mockEatery(id = 1).copy( + menuSummary = "Pasta and grill" + ), + isFavorite = true, + weekdayIndex = 0 + ), + filterText = "", + onCompareMenusClick = {}, + onToggleFavorite = {}, + onSendReport = { _, _, _ -> }, + onSelectEvent = { _, _, _ -> }, + onSetSelectedWeekdayIndex = {}, + onResetSelectedEvent = {}, + onSearchQueryChange = {}, + onToggleFavoriteMenuItem = {}, + ) +} From f281e010dc3c5f0b987f47477ae50e9b65193b41 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 01:48:23 -0500 Subject: [PATCH 100/126] Prevent race condition from getDeviceId --- .../repositories/UserPreferencesRepository.kt | 21 ++++++++++++++++--- .../data/repositories/UserRepository.kt | 11 +--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index ab72e1b5..7220ab1e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -23,7 +23,6 @@ class UserPreferencesRepository @Inject constructor( val notificationFlowCompletedFlow: Flow = userPreferencesFlow.map { it.notificationFlowCompleted } val analyticsDisabledFlow: Flow = userPreferencesFlow.map { it.analyticsDisabled } - val deviceIdFlow: Flow = userPreferencesFlow.map { it.deviceId.nullIfEmpty() } val accessTokenFlow: Flow = userPreferencesFlow.map { it.accessToken.nullIfEmpty() } val refreshTokenFlow: Flow = userPreferencesFlow.map { it.refreshToken.nullIfEmpty() } val isLoggedInFlow: Flow = userPreferencesFlow.map { it.isLoggedIn } @@ -87,8 +86,24 @@ class UserPreferencesRepository @Inject constructor( } } - suspend fun setDeviceId(deviceId: UUID) { - setPref { setDeviceId(deviceId.toString()) } + // This approach avoids race conditions by performing get and set inside + // updateData which is atomic + suspend fun getOrCreateDeviceId(): String { + var resolvedDeviceId: String? = null + userPreferencesStore.updateData { currentPreferences -> + val existingDeviceId = currentPreferences.deviceId.nullIfEmpty() + if (existingDeviceId != null) { + resolvedDeviceId = existingDeviceId + currentPreferences + } else { + val newDeviceId = UUID.randomUUID().toString() + resolvedDeviceId = newDeviceId + currentPreferences.toBuilder() + .setDeviceId(newDeviceId) + .build() + } + } + return checkNotNull(resolvedDeviceId) } private fun String?.nullIfEmpty(): String? = if (this.isNullOrEmpty()) null else this diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index a2212a43..4be34135 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.flow.update import retrofit2.HttpException import java.io.IOException import java.net.SocketTimeoutException -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @@ -62,15 +61,7 @@ class UserRepository @Inject constructor( private val useLocalFavorites = BuildConfig.USE_LOCAL_FAVORITES - suspend fun getDeviceId(): String { - val deviceId = userPreferencesRepository.deviceIdFlow.first() - if (deviceId != null) return deviceId - - // first launch - val uuid = UUID.randomUUID() - userPreferencesRepository.setDeviceId(uuid) - return uuid.toString() - } + suspend fun getDeviceId(): String = userPreferencesRepository.getOrCreateDeviceId() // called on app launch suspend fun getTokens(): Result = safeRequest { From 61becf21033228621f76134a582250d7dcdcbdae Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 03:17:21 -0400 Subject: [PATCH 101/126] Use collectAsStateWithLifecycle --- .../comparemenus/CompareMenusBotSheet.kt | 6 +++-- .../components/general/AppStoreRatingPopup.kt | 8 +++--- .../ui/components/general/EateryCard.kt | 7 +++-- .../eatery/ui/screens/CompareMenusScreen.kt | 13 +++++++--- .../eatery/ui/screens/EateryDetailScreen.kt | 11 ++++---- .../eatery/ui/screens/FavoritesScreen.kt | 8 +++--- .../android/eatery/ui/screens/HomeScreen.kt | 16 +++++++----- .../eatery/ui/screens/NearestScreen.kt | 8 +++--- .../eatery/ui/screens/ProfileScreen.kt | 12 +++++---- .../android/eatery/ui/screens/SearchScreen.kt | 26 ++++++++++++------- .../eatery/ui/screens/UpcomingMenuScreen.kt | 7 ++--- 11 files changed, 75 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/comparemenus/CompareMenusBotSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/comparemenus/CompareMenusBotSheet.kt index 28d3e98e..d2e16625 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/comparemenus/CompareMenusBotSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/comparemenus/CompareMenusBotSheet.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -35,6 +34,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.ui.components.general.FilterRow import com.cornellappdev.android.eatery.ui.theme.EateryBlue @@ -45,6 +46,7 @@ import com.cornellappdev.android.eatery.ui.viewmodels.CompareMenusBotViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +@OptIn(ExperimentalLifecycleComposeApi::class) @Composable fun CompareMenusBotSheet( onDismiss: () -> Unit, @@ -52,7 +54,7 @@ fun CompareMenusBotSheet( compareMenusBotViewModel: CompareMenusBotViewModel = hiltViewModel(), firstEatery: Eatery? = null ) { - val compareMenusUIState by compareMenusBotViewModel.compareMenusUiState.collectAsState() + val compareMenusUIState by compareMenusBotViewModel.compareMenusUiState.collectAsStateWithLifecycle() val filters = compareMenusUIState.filters val selectedEateries = compareMenusUIState.selected val eateries = compareMenusUIState.eateries diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/AppStoreRatingPopup.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/AppStoreRatingPopup.kt index 37bd2270..85ff8e0b 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/AppStoreRatingPopup.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/AppStoreRatingPopup.kt @@ -29,7 +29,6 @@ import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,6 +42,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat.startActivity +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.ui.components.home.EateryDetailLoadingScreen import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography @@ -52,12 +53,13 @@ import com.cornellappdev.android.eatery.util.appStorePopupRepository import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer +@OptIn(ExperimentalLifecycleComposeApi::class) @Composable fun AppStoreRatingPopup( navigateToSupport: () -> Unit, appStorePopupRepository: AppStorePopupRepository = appStorePopupRepository() ) { - val showPopup = appStorePopupRepository.popupShowing.collectAsState().value + val showPopup = appStorePopupRepository.popupShowing.collectAsStateWithLifecycle().value if (showPopup) { Dialog(appStorePopupRepository::dismissPopup) { AppStoreRatingDialog(navigateToSupport, appStorePopupRepository::dismissPopup) @@ -87,7 +89,7 @@ private fun AppStoreRatingDialog(navigateToSupport: () -> Unit, onDismiss: () -> Uri.parse("market://details?id=$packageName") ), null ) - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { startActivity( context, Intent( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt index 542e1491..15054f00 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/EateryCard.kt @@ -34,7 +34,6 @@ import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material.icons.outlined.Warning import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -50,6 +49,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.repositories.CoilRepository @@ -71,6 +72,7 @@ enum class EateryCardStyle { @OptIn( ExperimentalMaterialApi::class, + ExperimentalLifecycleComposeApi::class, ) @Composable fun EateryCard( @@ -82,7 +84,8 @@ fun EateryCard( style: EateryCardStyle = EateryCardStyle.DEFAULT, selectEatery: (eatery: Eatery) -> Unit = {} ) { - val xMinutesUntilClosing = eatery.calculateTimeUntilClosing()?.collectAsState()?.value + val xMinutesUntilClosing = + eatery.calculateTimeUntilClosing()?.collectAsStateWithLifecycle()?.value val interactionSource = remember { MutableInteractionSource() } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt index 814011b3..5b3372f2 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/CompareMenusScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,6 +62,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.Event @@ -82,7 +83,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.math.BigDecimal -@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@OptIn( + ExperimentalMaterialApi::class, + ExperimentalFoundationApi::class, + ExperimentalLifecycleComposeApi::class +) @Composable fun CompareMenusScreen( eateryIds: List, @@ -91,8 +96,8 @@ fun CompareMenusScreen( ) { compareMenusViewModel.openEatery(eateryIds) - val eateries by compareMenusViewModel.eateryFlow.collectAsState() - val events by compareMenusViewModel.eventFlow.collectAsState() + val eateries by compareMenusViewModel.eateryFlow.collectAsStateWithLifecycle() + val events by compareMenusViewModel.eventFlow.collectAsStateWithLifecycle() val modalBottomSheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt index 75bb608e..129a3038 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/EateryDetailScreen.kt @@ -56,7 +56,6 @@ import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -81,6 +80,8 @@ 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.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.Event @@ -132,15 +133,15 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalLifecycleComposeApi::class) @Composable fun EateryDetailScreen( eateryDetailViewModel: EateryDetailViewModel = hiltViewModel(), onCompareMenusClick: (selectedEateriesIds: List) -> Unit, ) { - val viewState = eateryDetailViewModel.eateryDetailViewState.collectAsState().value - val error by eateryDetailViewModel.error.collectAsState() - val filterText by eateryDetailViewModel.searchQueryFlow.collectAsState() + val viewState = eateryDetailViewModel.eateryDetailViewState.collectAsStateWithLifecycle().value + val error by eateryDetailViewModel.error.collectAsStateWithLifecycle() + val filterText by eateryDetailViewModel.searchQueryFlow.collectAsStateWithLifecycle() NetworkErrorToast( error = error, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt index a317f285..b2c6fcf0 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/FavoritesScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.material.IconButton import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,6 +41,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.ui.components.details.ToggleRow @@ -58,6 +59,7 @@ import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer +@OptIn(ExperimentalLifecycleComposeApi::class) @Composable fun FavoritesScreen( favoriteViewModel: FavoritesViewModel = hiltViewModel(), @@ -67,9 +69,9 @@ fun FavoritesScreen( ) { val shimmer = rememberShimmer(ShimmerBounds.View) val favoritesScreenViewState = - favoriteViewModel.favoritesScreenViewState.collectAsState().value + favoriteViewModel.favoritesScreenViewState.collectAsStateWithLifecycle().value var toggle by remember { mutableStateOf(true) } - val error by favoriteViewModel.error.collectAsState() + val error by favoriteViewModel.error.collectAsStateWithLifecycle() // TODO: replace with an actual error state NetworkErrorToast( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt index 1e6eb511..4ae71763 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/HomeScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -73,6 +72,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.BuildConfig import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery @@ -107,6 +108,7 @@ import kotlinx.coroutines.launch @OptIn( ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class, + ExperimentalLifecycleComposeApi::class, ) @Composable fun HomeScreen( @@ -119,12 +121,12 @@ fun HomeScreen( onNotificationsClick: () -> Unit ) { val context = LocalContext.current - val favorites = homeViewModel.favoriteEateries.collectAsState().value - val nearestEateries = homeViewModel.eateriesByDistance.collectAsState().value - val eateriesApiResponse = homeViewModel.eateryFlow.collectAsState().value - val filters = homeViewModel.filtersFlow.collectAsState().value - val error by homeViewModel.error.collectAsState() - val notificationFlowCompleted by homeViewModel.notificationFlowCompleted.collectAsState() + val favorites = homeViewModel.favoriteEateries.collectAsStateWithLifecycle().value + val nearestEateries = homeViewModel.eateriesByDistance.collectAsStateWithLifecycle().value + val eateriesApiResponse = homeViewModel.eateryFlow.collectAsStateWithLifecycle().value + val filters = homeViewModel.filtersFlow.collectAsStateWithLifecycle().value + val error by homeViewModel.error.collectAsStateWithLifecycle() + val notificationFlowCompleted by homeViewModel.notificationFlowCompleted.collectAsStateWithLifecycle() NetworkErrorToast( error = error, diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NearestScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NearestScreen.kt index d3dfae3a..f65b829f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NearestScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/NearestScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -25,6 +24,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.ui.components.general.EateryCard @@ -38,14 +39,15 @@ import com.valentinilk.shimmer.rememberShimmer /** * The Nearest to You screen that shows eateries sorted by walk times. */ +@OptIn(ExperimentalLifecycleComposeApi::class) @Composable fun NearestScreen( nearestViewModel: NearestViewModel = hiltViewModel(), onEateryClick: (eatery: Eatery) -> Unit ) { rememberShimmer(ShimmerBounds.View) - val nearestEateries = nearestViewModel.nearestEateries.collectAsState().value - val favorites = nearestViewModel.favoriteEateries.collectAsState().value + val nearestEateries = nearestViewModel.nearestEateries.collectAsStateWithLifecycle().value + val favorites = nearestViewModel.favoriteEateries.collectAsStateWithLifecycle().value Column( modifier = Modifier diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index b2692aa9..e6c418f4 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -2,9 +2,10 @@ package com.cornellappdev.android.eatery.ui.screens import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.data.models.AccountBalances import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.data.models.TransactionAccountType @@ -14,7 +15,7 @@ import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel -@OptIn(ExperimentalAnimationApi::class) +@OptIn(ExperimentalAnimationApi::class, ExperimentalLifecycleComposeApi::class) @Composable fun ProfileScreen( loginViewModel: LoginViewModel = hiltViewModel(), @@ -22,13 +23,14 @@ fun ProfileScreen( webViewEnabled: Boolean, onBackClick: () -> Unit ) { - val state = loginViewModel.state.collectAsState(initial = LoginViewModel.State.Login()).value + val state = + loginViewModel.state.collectAsStateWithLifecycle(initialValue = LoginViewModel.State.Login()).value val filteredTransactions = - loginViewModel.filteredTransactionsFlow.collectAsState(initial = emptyList()).value + loginViewModel.filteredTransactionsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value // todo - replace toasts with actual error state if (state is LoginViewModel.State.Login) { - val error by loginViewModel.error.collectAsState() + val error by loginViewModel.error.collectAsStateWithLifecycle() NetworkErrorToast( error = error, onErrorShown = loginViewModel::clearError diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt index 8f762d10..9135c359 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SearchScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember @@ -50,6 +49,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.R import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.ui.components.general.EateryCard @@ -70,7 +71,11 @@ import com.skydoves.landscapist.glide.GlideImage import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@OptIn( + ExperimentalMaterialApi::class, + ExperimentalFoundationApi::class, + ExperimentalLifecycleComposeApi::class +) @Composable fun SearchScreen( searchViewModel: SearchViewModel = hiltViewModel(), @@ -84,13 +89,14 @@ fun SearchScreen( ) val coroutineScope = rememberCoroutineScope() - val query by searchViewModel.searchFlow.collectAsState() - val favorites = searchViewModel.favoriteEateries.collectAsState().value + val query by searchViewModel.searchFlow.collectAsStateWithLifecycle() + val favorites = searchViewModel.favoriteEateries.collectAsStateWithLifecycle().value val recentSearches = - searchViewModel.recentSearches.collectAsState().value.reversed().take(10).distinct() - val filters = searchViewModel.filtersFlow.collectAsState().value - val searchResponse = searchViewModel.searchResultEateries.collectAsState().value - val error by searchViewModel.error.collectAsState() + searchViewModel.recentSearches.collectAsStateWithLifecycle().value.reversed().take(10) + .distinct() + val filters = searchViewModel.filtersFlow.collectAsStateWithLifecycle().value + val searchResponse = searchViewModel.searchResultEateries.collectAsStateWithLifecycle().value + val error by searchViewModel.error.collectAsStateWithLifecycle() NetworkErrorToast( error = error, @@ -250,8 +256,8 @@ fun SearchScreen( recentSearches.forEach { eateryId -> val eateryResponse = - searchViewModel.openEatery(eateryId).collectAsState( - initial = EateryApiResponse.Pending + searchViewModel.openEatery(eateryId).collectAsStateWithLifecycle( + initialValue = EateryApiResponse.Pending ).value if (eateryResponse is EateryApiResponse.Success) { Box( diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt index 29450579..7b2ae02c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/UpcomingMenuScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -40,6 +39,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.ui.components.general.CalendarWeekSelector import com.cornellappdev.android.eatery.ui.components.general.Filter import com.cornellappdev.android.eatery.ui.components.general.FilterButton @@ -65,7 +66,7 @@ import java.time.format.DateTimeFormatter @OptIn( ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, - ExperimentalAnimationApi::class + ExperimentalAnimationApi::class, ExperimentalLifecycleComposeApi::class ) @Composable @@ -78,7 +79,7 @@ fun UpcomingMenuScreen( skipHalfExpanded = true, initialValue = ModalBottomSheetValue.Hidden ) - val viewState = upcomingViewModel.viewStateFlow.collectAsState().value + val viewState = upcomingViewModel.viewStateFlow.collectAsStateWithLifecycle().value val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() From 79430320b74d2d219c4f3d4ee72cf9429e0da2b3 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 03:35:33 -0400 Subject: [PATCH 102/126] Unregister listener in onStop --- .../java/com/cornellappdev/android/eatery/MainActivity.kt | 6 ++++++ .../eatery/ui/components/general/NetworkErrorToast.kt | 4 +--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 705cdc35..385fd191 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -71,8 +71,14 @@ class MainActivity : ComponentActivity() { } } + override fun onStop() { + super.onStop() + unregisterFlexibleUpdateListener() + } + override fun onDestroy() { super.onDestroy() + // in case onStop cleanup did not run unregisterFlexibleUpdateListener() } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/NetworkErrorToast.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/NetworkErrorToast.kt index daeb5c2e..8661f7d8 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/NetworkErrorToast.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/general/NetworkErrorToast.kt @@ -26,6 +26,4 @@ fun NetworkErrorToast( onErrorShown() } } -} - - +} \ No newline at end of file From 8a25535410fd49ef98061bc155431f4c6196ec49 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 03:38:24 -0400 Subject: [PATCH 103/126] Improve flow practice --- .../data/repositories/UserPreferencesRepository.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 7220ab1e..4f35c76e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -2,13 +2,8 @@ package com.cornellappdev.android.eatery.data.repositories import androidx.datastore.core.DataStore import com.cornellappdev.android.eatery.UserPreferences -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -33,9 +28,7 @@ class UserPreferencesRepository @Inject constructor( val favoriteItemNamesFlow: Flow> = userPreferencesFlow.map { it.itemFavoritesMap.keys.toList() } - val recentSearchesFlow: StateFlow> = userPreferencesFlow.map { prefs -> - prefs.recentSearchesList - }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, listOf()) + val recentSearchesFlow: Flow> = userPreferencesFlow.map { it.recentSearchesList } suspend fun setHasOnboarded(hasOnboarded: Boolean) = setPref { setHasOnboarded(hasOnboarded) From feead089860f472dc3b956aaa8a405495689923e Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 03:42:50 -0400 Subject: [PATCH 104/126] Add limit count and duplicate checking to recent searches --- .../data/repositories/UserPreferencesRepository.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 4f35c76e..d8552002 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -12,6 +12,10 @@ import javax.inject.Singleton class UserPreferencesRepository @Inject constructor( private val userPreferencesStore: DataStore, ) { + companion object { + private const val MAX_RECENT_SEARCHES = 20 + } + private val userPreferencesFlow: Flow = userPreferencesStore.data val hasOnboardedFlow: Flow = userPreferencesFlow.map { it.hasOnboarded } @@ -43,7 +47,13 @@ class UserPreferencesRepository @Inject constructor( } suspend fun addRecentSearch(eateryId: Int) = setPref { - addRecentSearches(eateryId) + val updatedRecentSearches = recentSearchesList + .filter { it != eateryId } + .toMutableList() + .apply { add(eateryId) } + .takeLast(MAX_RECENT_SEARCHES) + clearRecentSearches() + addAllRecentSearches(updatedRecentSearches) } suspend fun setFavoriteEateryName(eateryName: String, isFavorite: Boolean) { From 8ca48fe847ef75b4ef01900536ba87522bbe495f Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 04:08:12 -0400 Subject: [PATCH 105/126] Extract AuthTokenRepository from UserRepository --- .../android/eatery/MainActivity.kt | 8 +- .../data/repositories/AuthTokenRepository.kt | 201 ++++++++++++++++++ .../data/repositories/UserRepository.kt | 160 +++----------- .../eatery/ui/viewmodels/HomeViewModel.kt | 6 +- .../eatery/ui/viewmodels/LoginViewModel.kt | 4 +- 5 files changed, 239 insertions(+), 140 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt index 385fd191..4a48060d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope +import com.cornellappdev.android.eatery.data.repositories.AuthTokenRepository import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.navigation.NavigationSetup @@ -33,6 +34,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var userRepository: UserRepository + @Inject + lateinit var authTokenRepository: AuthTokenRepository + private lateinit var activityResultLauncher: ActivityResultLauncher private val appUpdateManager by lazy { AppUpdateManagerFactory.create(applicationContext) } private var flexibleUpdateListener: InstallStateUpdatedListener? = null @@ -67,7 +71,7 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { configureTokens() userRepository.updateFavorites() - userRepository.markTokensAsConfigured() + authTokenRepository.markTokensAsConfigured() } } @@ -148,6 +152,6 @@ class MainActivity : ComponentActivity() { } private suspend fun configureTokens() { - userRepository.getTokens() + authTokenRepository.getTokens() } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt new file mode 100644 index 00000000..965d609a --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt @@ -0,0 +1,201 @@ +package com.cornellappdev.android.eatery.data.repositories + +import com.cornellappdev.android.eatery.data.NetworkApi +import com.cornellappdev.android.eatery.data.models.DeviceId +import com.cornellappdev.android.eatery.data.models.LoginPIN +import com.cornellappdev.android.eatery.data.models.LoginRequest +import com.cornellappdev.android.eatery.data.models.NetworkError +import com.cornellappdev.android.eatery.data.models.RefreshRequest +import com.cornellappdev.android.eatery.data.models.Result +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import retrofit2.HttpException +import java.io.IOException +import java.net.SocketTimeoutException +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.random.Random + +/** + * Repository responsible for managing authentication tokens and token-related operations. + * Separates auth/token concerns from other user repository responsibilities. + */ +@Singleton +class AuthTokenRepository @Inject constructor( + private val networkApi: NetworkApi, + private val userPreferencesRepository: UserPreferencesRepository +) { + private val _tokensConfiguredFlow: MutableStateFlow = MutableStateFlow(false) + + /** + * A [StateFlow] that emits whether tokens have been configured successfully. + */ + val tokensConfiguredFlow: StateFlow = _tokensConfiguredFlow.asStateFlow() + + /** + * Gets or creates a device ID. + */ + suspend fun getDeviceId(): String = userPreferencesRepository.getOrCreateDeviceId() + + /** + * Fetches initial tokens from the API based on device ID. + * Called on app launch. + */ + suspend fun getTokens(): Result = safeRequest { + val deviceId = getDeviceId() + val response = networkApi.verifyToken(DeviceId(deviceId)) + val accessToken = response.accessToken + val refreshToken = response.refreshToken + if (accessToken != null) { + userPreferencesRepository.setAccessToken(accessToken) + } else { + throw Exception("Access token is null") + } + if (refreshToken != null) { + userPreferencesRepository.setRefreshToken(refreshToken) + } else { + throw Exception("Refresh token is null") + } + } + + /** + * Marks tokens as configured after successful initialization. + */ + fun markTokensAsConfigured() { + _tokensConfiguredFlow.value = true + } + + /** + * Refreshes the access token using the refresh token. + */ + suspend fun refreshTokens(): Result = safeRequest { + val deviceId = getDeviceId() + val refreshToken = userPreferencesRepository.refreshTokenFlow.first() + ?: throw IllegalStateException("Refresh token not available") + val tokens = networkApi.refreshToken( + RefreshRequest( + deviceId = deviceId, + refreshToken = refreshToken + ) + ) + val accessToken = tokens.accessToken + val newRefreshToken = tokens.refreshToken + if (accessToken != null) { + userPreferencesRepository.setAccessToken(accessToken) + } else { + throw Exception("Access token is null") + } + if (newRefreshToken != null) { + userPreferencesRepository.setRefreshToken(newRefreshToken) + } else { + throw Exception("Refresh token is null") + } + } + + /** + * Gets the current access token with Bearer prefix. + * Assumes device has been registered. + */ + suspend fun getAccessToken(): String = + prependBearer( + userPreferencesRepository.accessTokenFlow.first() + ?: throw IllegalStateException("Access token not available") + ) + + /** + * Links a GET account by storing session ID and PIN, then authorizing with the API. + */ + suspend fun linkGETAccount(sessionId: String): Result { + userPreferencesRepository.setSessionId(sessionId) + val pin = Random.nextInt(10000) + userPreferencesRepository.setPin(pin) + return tryRequestWithResult { + networkApi.authorizeUser( + accessToken = getAccessToken(), + loginRequest = LoginRequest(pin.toString(), sessionId) + ) + } + } + + /** + * Refreshes the GET session ID using the stored PIN. + */ + suspend fun refreshLogin(pin: Int): Result = tryRequestWithResult { + val newSessionId = networkApi.refreshAuthorizedUser( + accessToken = getAccessToken(), + loginPIN = LoginPIN(pin.toString()) + ).sessionId + if (newSessionId == null) { + throw Exception("Session ID is null") + } else { + userPreferencesRepository.setSessionId(newSessionId) + } + } + + /** + * Gets the stored session ID. + */ + suspend fun getSessionId(): String = userPreferencesRepository.sessionIdFlow.first() + + /** + * Gets the stored PIN. + */ + suspend fun getPin(): Int = userPreferencesRepository.pinFlow.first() + + /** + * Clears tokens and authentication data (logout). + */ + suspend fun clearAuthTokens() { + userPreferencesRepository.setSessionId("") + userPreferencesRepository.setAccessToken("") + userPreferencesRepository.setRefreshToken("") + } + + /** + * Converts exceptions into appropriate [NetworkError] types. + */ + private fun handleException(e: Exception): NetworkError = when (e) { + is HttpException -> when (e.code()) { + 401, 403 -> NetworkError.Unauthorized + in 400..599 -> NetworkError.ServerError(e.code(), e.message()) + else -> NetworkError.Unknown(e) + } + + is SocketTimeoutException -> NetworkError.Timeout + is IOException -> NetworkError.NetworkFailure + else -> NetworkError.Unknown(e) + } + + /** + * Safely executes a network request and wraps the result in a [Result] object. + */ + private suspend fun safeRequest(request: suspend () -> T): Result { + return try { + Result.Success(request()) + } catch (e: Exception) { + Result.Error(handleException(e)) + } + } + + /** + * Tries to make the given request, and if it fails, refreshes tokens and tries again. + * Returns a [Result] wrapping the response or error. + */ + private suspend fun tryRequestWithResult(request: suspend () -> T): Result { + return try { + Result.Success(request()) + } catch (_: Exception) { + try { + refreshTokens() + Result.Success(request()) + } catch (retryException: Exception) { + Result.Error(handleException(retryException)) + } + } + } + + private fun prependBearer(str: String) = "Bearer $str" +} + diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 4be34135..bc7c8150 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -2,14 +2,10 @@ package com.cornellappdev.android.eatery.data.repositories import com.cornellappdev.android.eatery.BuildConfig import com.cornellappdev.android.eatery.data.NetworkApi -import com.cornellappdev.android.eatery.data.models.DeviceId import com.cornellappdev.android.eatery.data.models.FavoriteEatery import com.cornellappdev.android.eatery.data.models.FavoriteItem import com.cornellappdev.android.eatery.data.models.Financials -import com.cornellappdev.android.eatery.data.models.LoginPIN -import com.cornellappdev.android.eatery.data.models.LoginRequest import com.cornellappdev.android.eatery.data.models.NetworkError -import com.cornellappdev.android.eatery.data.models.RefreshRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody import com.cornellappdev.android.eatery.data.models.Result import com.cornellappdev.android.eatery.data.models.SessionID @@ -24,12 +20,12 @@ import java.io.IOException import java.net.SocketTimeoutException import javax.inject.Inject import javax.inject.Singleton -import kotlin.random.Random @Singleton class UserRepository @Inject constructor( private val networkApi: NetworkApi, - private val userPreferencesRepository: UserPreferencesRepository + private val userPreferencesRepository: UserPreferencesRepository, + private val authTokenRepository: AuthTokenRepository ) { private val _loadedUser: MutableStateFlow = MutableStateFlow(null) @@ -52,39 +48,8 @@ class UserRepository @Inject constructor( * A [StateFlow] emitting a list of the names of the user's favorite menu items. */ val favoriteItemsFlow: StateFlow> = _favoriteItemsFlow.asStateFlow() - private val _tokensConfiguredFlow: MutableStateFlow = MutableStateFlow(false) - - /** - * A [StateFlow] that emits whether configureTokens() has completed successfully. - */ - val tokensConfiguredFlow: StateFlow = _tokensConfiguredFlow.asStateFlow() - private val useLocalFavorites = BuildConfig.USE_LOCAL_FAVORITES - suspend fun getDeviceId(): String = userPreferencesRepository.getOrCreateDeviceId() - - // called on app launch - suspend fun getTokens(): Result = safeRequest { - val deviceId = getDeviceId() - val response = networkApi.verifyToken(DeviceId(deviceId)) - val accessToken = response.accessToken - val refreshToken = response.refreshToken - if (accessToken != null) { - userPreferencesRepository.setAccessToken(accessToken) - } else { - throw Exception("Access token is null") - } - if (refreshToken != null) { - userPreferencesRepository.setRefreshToken(refreshToken) - } else { - throw Exception("Refresh token is null") - } - } - - fun markTokensAsConfigured() { - _tokensConfiguredFlow.value = true - } - suspend fun updateFavorites(): Result { if (useLocalFavorites) { _favoriteEateriesFlow.value = userPreferencesRepository.favoriteEateryNamesFlow.first() @@ -93,7 +58,7 @@ class UserRepository @Inject constructor( } return tryRequestWithResult { - val accessPhrase = getAccessToken() + val accessPhrase = authTokenRepository.getAccessToken() val matches = networkApi.getFavoriteMatches(accessToken = accessPhrase) _favoriteEateriesFlow.value = matches.mapNotNull { it.eateryName } _favoriteItemsFlow.value = run { @@ -125,7 +90,7 @@ class UserRepository @Inject constructor( return tryRequestWithResult { networkApi.addFavoriteItem( - accessToken = getAccessToken(), + accessToken = authTokenRepository.getAccessToken(), item = FavoriteItem(item = name) ) _favoriteItemsFlow.update { currentItems -> @@ -145,7 +110,7 @@ class UserRepository @Inject constructor( return tryRequestWithResult { networkApi.deleteFavoriteItem( - accessToken = getAccessToken(), + accessToken = authTokenRepository.getAccessToken(), item = FavoriteItem(name) ) _favoriteItemsFlow.update { currentItems -> @@ -165,7 +130,7 @@ class UserRepository @Inject constructor( return tryRequestWithResult { networkApi.addFavoriteEatery( - accessToken = getAccessToken(), + accessToken = authTokenRepository.getAccessToken(), eatery = FavoriteEatery(id), ) _favoriteEateriesFlow.update { currentEateries -> @@ -185,7 +150,7 @@ class UserRepository @Inject constructor( return tryRequestWithResult { networkApi.deleteFavoriteEatery( - accessToken = getAccessToken(), + accessToken = authTokenRepository.getAccessToken(), eatery = FavoriteEatery(id) ) _favoriteEateriesFlow.update { currentEateries -> @@ -194,31 +159,20 @@ class UserRepository @Inject constructor( } } - suspend fun linkGETAccount(sessionId: String): Result { - userPreferencesRepository.setSessionId(sessionId) - val pin = Random.nextInt(10000) - userPreferencesRepository.setPin(pin) - return tryRequestWithResult { - networkApi.authorizeUser( - accessToken = getAccessToken(), - loginRequest = LoginRequest(pin.toString(), sessionId) - ) - } - } suspend fun getFinancials(): Result = tryRequestWithResult { var financials: Financials try { financials = networkApi.getFinancials( - accessToken = getAccessToken(), - sessionId = SessionID(userPreferencesRepository.sessionIdFlow.first()) + accessToken = authTokenRepository.getAccessToken(), + sessionId = SessionID(authTokenRepository.getSessionId()) ) } catch (_: Exception) { - val pin = userPreferencesRepository.pinFlow.first() - refreshLogin(pin = pin) + val pin = authTokenRepository.getPin() + authTokenRepository.refreshLogin(pin = pin) financials = networkApi.getFinancials( - accessToken = getAccessToken(), - sessionId = SessionID(userPreferencesRepository.sessionIdFlow.first()) + accessToken = authTokenRepository.getAccessToken(), + sessionId = SessionID(authTokenRepository.getSessionId()) ) } _loadedUser.value = User( @@ -236,55 +190,14 @@ class UserRepository @Inject constructor( suspend fun isLoggedIn(): Boolean = userPreferencesRepository.isLoggedInFlow.first() - /** - * Refreshes GET sessionID. - */ - suspend fun refreshLogin(pin: Int): Result = tryRequestWithResult { - val newSessionId = networkApi.refreshAuthorizedUser( - accessToken = getAccessToken(), - loginPIN = LoginPIN(pin.toString()) - ).sessionId - if (newSessionId == null) { - throw Exception("Session ID is null") - } else { - userPreferencesRepository.setSessionId(newSessionId) - } - } - suspend fun logout() { _loadedUser.value = null - userPreferencesRepository.setSessionId("") + authTokenRepository.clearAuthTokens() userPreferencesRepository.setIsLoggedIn(false) } suspend fun hasOnboarded(): Boolean = userPreferencesRepository.hasOnboardedFlow.first() - /** - * Converts exceptions into appropriate [NetworkError] types. - */ - private fun handleException(e: Exception): NetworkError = when (e) { - is HttpException -> when (e.code()) { - 401, 403 -> NetworkError.Unauthorized - in 400..599 -> NetworkError.ServerError(e.code(), e.message()) - else -> NetworkError.Unknown(e) - } - - is SocketTimeoutException -> NetworkError.Timeout - is IOException -> NetworkError.NetworkFailure - else -> NetworkError.Unknown(e) - } - - /** - * Safely executes a network request and wraps the result in a [Result] object. - */ - private suspend fun safeRequest(request: suspend () -> T): Result { - return try { - Result.Success(request()) - } catch (e: Exception) { - Result.Error(handleException(e)) - } - } - /** * Tries to make the given request, and if it fails, refreshes tokens and tries again. * Returns a [Result] wrapping the response or error. @@ -294,7 +207,7 @@ class UserRepository @Inject constructor( Result.Success(request()) } catch (_: Exception) { try { - refreshTokens() + authTokenRepository.refreshTokens() Result.Success(request()) } catch (retryException: Exception) { Result.Error(handleException(retryException)) @@ -303,40 +216,17 @@ class UserRepository @Inject constructor( } /** - * Gets refresh token assuming device has been registered + * Converts exceptions into appropriate [NetworkError] types. */ - private suspend fun refreshTokens() { - val deviceId = getDeviceId() - val refreshToken = userPreferencesRepository.refreshTokenFlow.first() - ?: throw IllegalStateException("Refresh token not available") - val tokens = networkApi.refreshToken( - RefreshRequest( - deviceId = deviceId, - refreshToken = refreshToken - ) - ) - val accessToken = tokens.accessToken - val newRefreshToken = tokens.refreshToken - if (accessToken != null) { - userPreferencesRepository.setAccessToken(accessToken) - } else { - throw Exception("Access token is null") - } - if (newRefreshToken != null) { - userPreferencesRepository.setRefreshToken(newRefreshToken) - } else { - throw Exception("Refresh token is null") + private fun handleException(e: Exception): NetworkError = when (e) { + is HttpException -> when (e.code()) { + 401, 403 -> NetworkError.Unauthorized + in 400..599 -> NetworkError.ServerError(e.code(), e.message()) + else -> NetworkError.Unknown(e) } - } - /** - * Gets access token with Bearer prefix assuming device has been registered - */ - private suspend fun getAccessToken(): String = - prependBearer( - userPreferencesRepository.accessTokenFlow.first() - ?: throw IllegalStateException("Access token not available") - ) - - private fun prependBearer(str: String) = "Bearer $str" + is SocketTimeoutException -> NetworkError.Timeout + is IOException -> NetworkError.NetworkFailure + else -> NetworkError.Unknown(e) + } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt index 89226693..60705ccd 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/HomeViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.android.eatery.data.models.Eatery import com.cornellappdev.android.eatery.data.models.Result +import com.cornellappdev.android.eatery.data.repositories.AuthTokenRepository import com.cornellappdev.android.eatery.data.repositories.EateryRepository import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository @@ -32,7 +33,8 @@ import javax.inject.Inject class HomeViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, private val eateryRepository: EateryRepository, - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val authTokenRepository: AuthTokenRepository ) : ViewModel() { private val _filtersFlow: MutableStateFlow> = MutableStateFlow(listOf()) @@ -206,7 +208,7 @@ class HomeViewModel @Inject constructor( } fun updateFavoritesIfTokensConfigured() { - if (userRepository.tokensConfiguredFlow.value) { + if (authTokenRepository.tokensConfiguredFlow.value) { viewModelScope.launch { when (val result = userRepository.updateFavorites()) { is Result.Success -> { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 51188dd5..86bdb312 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -8,6 +8,7 @@ import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.data.models.TransactionAccountType import com.cornellappdev.android.eatery.data.models.User import com.cornellappdev.android.eatery.data.models.toTransactionAccountType +import com.cornellappdev.android.eatery.data.repositories.AuthTokenRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkAction import com.cornellappdev.android.eatery.ui.viewmodels.state.NetworkUiError @@ -24,6 +25,7 @@ import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( private val userRepository: UserRepository, + private val authTokenRepository: AuthTokenRepository, ) : ViewModel() { /** @@ -145,7 +147,7 @@ class LoginViewModel @Inject constructor( * Returns true if the account was linked successfully, false otherwise. */ private suspend fun linkGETAccount(sessionId: String): Boolean { - return when (val result = userRepository.linkGETAccount(sessionId)) { + return when (val result = authTokenRepository.linkGETAccount(sessionId)) { is Result.Success -> { userRepository.setIsLoggedIn(true) _error.value = null From bcb87c7d4aa3b295195cf331556369cad7874526 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 04:12:01 -0400 Subject: [PATCH 106/126] Remove redundant comments --- .../data/repositories/AuthTokenRepository.kt | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt index 965d609a..f77581ef 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt @@ -18,10 +18,6 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random -/** - * Repository responsible for managing authentication tokens and token-related operations. - * Separates auth/token concerns from other user repository responsibilities. - */ @Singleton class AuthTokenRepository @Inject constructor( private val networkApi: NetworkApi, @@ -34,9 +30,6 @@ class AuthTokenRepository @Inject constructor( */ val tokensConfiguredFlow: StateFlow = _tokensConfiguredFlow.asStateFlow() - /** - * Gets or creates a device ID. - */ suspend fun getDeviceId(): String = userPreferencesRepository.getOrCreateDeviceId() /** @@ -60,16 +53,10 @@ class AuthTokenRepository @Inject constructor( } } - /** - * Marks tokens as configured after successful initialization. - */ fun markTokensAsConfigured() { _tokensConfiguredFlow.value = true } - /** - * Refreshes the access token using the refresh token. - */ suspend fun refreshTokens(): Result = safeRequest { val deviceId = getDeviceId() val refreshToken = userPreferencesRepository.refreshTokenFlow.first() @@ -94,19 +81,12 @@ class AuthTokenRepository @Inject constructor( } } - /** - * Gets the current access token with Bearer prefix. - * Assumes device has been registered. - */ suspend fun getAccessToken(): String = prependBearer( userPreferencesRepository.accessTokenFlow.first() ?: throw IllegalStateException("Access token not available") ) - /** - * Links a GET account by storing session ID and PIN, then authorizing with the API. - */ suspend fun linkGETAccount(sessionId: String): Result { userPreferencesRepository.setSessionId(sessionId) val pin = Random.nextInt(10000) @@ -119,9 +99,6 @@ class AuthTokenRepository @Inject constructor( } } - /** - * Refreshes the GET session ID using the stored PIN. - */ suspend fun refreshLogin(pin: Int): Result = tryRequestWithResult { val newSessionId = networkApi.refreshAuthorizedUser( accessToken = getAccessToken(), @@ -134,19 +111,10 @@ class AuthTokenRepository @Inject constructor( } } - /** - * Gets the stored session ID. - */ suspend fun getSessionId(): String = userPreferencesRepository.sessionIdFlow.first() - /** - * Gets the stored PIN. - */ suspend fun getPin(): Int = userPreferencesRepository.pinFlow.first() - /** - * Clears tokens and authentication data (logout). - */ suspend fun clearAuthTokens() { userPreferencesRepository.setSessionId("") userPreferencesRepository.setAccessToken("") From 116c7b22d8956ff15acc146f87d698f0124582fd Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 04:15:26 -0400 Subject: [PATCH 107/126] Remove one-item column --- .../eatery/ui/components/login/AccountPage.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 32a909de..9fe8b02e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -431,19 +431,16 @@ private fun AccountPageHeader( tint = Color.White ) } - Column( + Text( modifier = Modifier.padding( start = 16.dp, end = 16.dp, top = 24.dp - ) - ) { - Text( - text = "Account", - color = Color.White, - style = EateryBlueTypography.h2 - ) - } + ), + text = "Account", + color = Color.White, + style = EateryBlueTypography.h2 + ) } } } From 4be93e988458994f6e0b8281b708556eab0d5418 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 04:19:58 -0400 Subject: [PATCH 108/126] Remove duplicate statusBarPadding --- .../android/eatery/ui/components/login/AccountPage.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 9fe8b02e..75567c1d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -414,15 +414,13 @@ private fun AccountPageHeader( modifier = Modifier .fillMaxWidth() .background(color = EateryBlue) - .then(Modifier.statusBarsPadding()) .padding(bottom = 7.dp), ) { IconButton( modifier = Modifier .padding(end = 16.dp) .align(Alignment.End) - .size(32.dp) - .statusBarsPadding(), + .size(32.dp), onClick = { onSettingsClicked() }) { Icon( modifier = Modifier.size(28.dp), From b89f02179f48e669097d685429a9b6bdb6118e4a Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sun, 8 Mar 2026 04:22:46 -0400 Subject: [PATCH 109/126] Fix index issue --- .../android/eatery/ui/components/login/AccountPage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 75567c1d..e565c1ea 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -637,7 +637,7 @@ fun AccountTypesSelector( ) } } - if (index != selectedPaymentMethod.size) { + if (index != selectedPaymentMethod.lastIndex) { Spacer( modifier = Modifier .fillMaxWidth() From 17168e843c9f01e7b4b8b6f8571f96e534add59d Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 00:40:31 -0400 Subject: [PATCH 110/126] Add preview for EateryMenusBottomSheet --- .../details/EateryMenusBottomSheet.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt index b89f225a..861c9203 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt @@ -34,14 +34,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle 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 com.cornellappdev.android.eatery.data.models.Eatery +import com.cornellappdev.android.eatery.data.models.Event import com.cornellappdev.android.eatery.data.models.MealTime import com.cornellappdev.android.eatery.ui.components.general.CalendarWeekSelector import com.cornellappdev.android.eatery.ui.theme.EateryBlue import com.cornellappdev.android.eatery.ui.theme.EateryBlueTypography import com.cornellappdev.android.eatery.ui.theme.GrayZero +import com.cornellappdev.android.eatery.util.EateryPreview +import com.cornellappdev.android.eatery.util.PreviewData import com.cornellappdev.android.eatery.util.toMealTypeDisplayName import com.cornellappdev.android.eatery.util.toReadableShortName import java.time.DayOfWeek @@ -250,3 +254,43 @@ fun EateryMenusBottomSheet( } } } + +@Preview(showBackground = true) +@Composable +private fun EateryMenusBottomSheetPreview() = EateryPreview { + val zoneId = ZoneId.of("America/New_York") + val today = LocalDate.now(zoneId) + val previewEatery = PreviewData.mockEatery().copy( + events = listOf( + Event( + type = "Breakfast", + startTimestamp = today.atTime(8, 0), + endTimestamp = today.atTime(10, 0) + ), + Event( + type = "Lunch", + startTimestamp = today.atTime(11, 0), + endTimestamp = today.atTime(14, 0) + ), + Event( + type = "Dinner", + startTimestamp = today.atTime(17, 0), + endTimestamp = today.atTime(20, 0) + ), + Event( + type = "Lunch", + startTimestamp = today.plusDays(1).atTime(11, 0), + endTimestamp = today.plusDays(1).atTime(14, 0) + ) + ) + ) + + EateryMenusBottomSheet( + weekDayIndex = 0, + mealType = 1, + onDismiss = {}, + eatery = previewEatery, + onShowMenuClick = { _, _, _ -> }, + onResetClick = {} + ) +} From 3101ba4625f52547bea52f43dbeeb006862b516c Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 00:43:44 -0400 Subject: [PATCH 111/126] Replace spacer with divider --- .../ui/components/details/EateryMenusBottomSheet.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt index 861c9203..80cc9759 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/details/EateryMenusBottomSheet.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -17,6 +16,7 @@ import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card +import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Text @@ -196,12 +196,12 @@ fun EateryMenusBottomSheet( } } if (mealTypes.lastIndex != index) { - Spacer( + Divider( modifier = Modifier .padding(top = 12.dp, bottom = 12.dp) - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) + .fillMaxWidth(), + thickness = 1.dp, + color = GrayZero ) } } From 1d12c4fed6b0f7ef311b0a63a0732b2f647bfb12 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 02:32:06 -0400 Subject: [PATCH 112/126] Hoist filterText --- .../android/eatery/ui/components/login/AccountPage.kt | 8 ++------ .../android/eatery/ui/screens/ProfileScreen.kt | 3 +++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index e565c1ea..8b82b932 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -83,14 +83,10 @@ fun AccountPage( accountTypeBalance: AccountBalances, onSettingsClicked: () -> Unit, filteredTransactions: List, + filterText: String, onQueryChanged: (String) -> Unit, updateAccountFilter: (TransactionAccountType) -> Unit ) { - var filterText by remember { mutableStateOf("") } - val onFilterTextChanged = { newText: String -> - filterText = newText - onQueryChanged(newText) - } val modalBottomSheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, @@ -137,7 +133,7 @@ fun AccountPage( accountFilter, showBottomSheet = modalBottomSheetState::show, filterText, - setFilterText = onFilterTextChanged, + setFilterText = onQueryChanged, filteredTransactions, setSheetContent = { sheetContent = it }, ) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index e6c418f4..c16fd90c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -48,6 +48,7 @@ fun ProfileScreen( onModalHidden = loginViewModel::onLoginExited, onSettingsClicked = onSettingsClicked, accountFilter = if (state is LoginViewModel.State.Account) state.accountFilter else TransactionAccountType.BRBS, + filterText = if (state is LoginViewModel.State.Account) state.query else "", filteredTransactions = filteredTransactions, onQueryChanged = loginViewModel::setQuery, updateAccountFilter = loginViewModel::updateAccountFilter @@ -65,6 +66,7 @@ private fun ProfileScreenContent( onBackClick: () -> Unit, onModalHidden: () -> Unit, accountFilter: TransactionAccountType, + filterText: String, onSettingsClicked: () -> Unit, filteredTransactions: List, onQueryChanged: (String) -> Unit, @@ -85,6 +87,7 @@ private fun ProfileScreenContent( accountTypeBalance = accountTypeBalance, onSettingsClicked = onSettingsClicked, filteredTransactions = filteredTransactions, + filterText = filterText, onQueryChanged = onQueryChanged, updateAccountFilter = updateAccountFilter ) From daefb4b735eadc03d641df702f86fac54672dc7e Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 02:36:46 -0400 Subject: [PATCH 113/126] Add key for lazycolumn --- .../android/eatery/ui/components/login/AccountPage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 8b82b932..92bef061 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -238,7 +238,7 @@ private fun AccountPageContent( setFilterText ) } - items(filteredTransactions) { + items(items = filteredTransactions, key = { it.date + it.location + it.amount }) { TransactionRow( transaction = it, isMealSwipes = accountFilter == TransactionAccountType.MEAL_SWIPES From c63bb3b5ddca2c4fef5ec3804cf3bd0100a954dc Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 02:40:15 -0400 Subject: [PATCH 114/126] Replace spacers with dividers --- .../eatery/ui/components/login/AccountPage.kt | 55 ++++--------------- 1 file changed, 11 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 92bef061..e953b808 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -180,36 +181,21 @@ private fun AccountPageContent( swipes = it ) } - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) + Divider(color = GrayZero, thickness = 1.dp) accountTypeBalance.brbBalance?.let { AccountBalanceRow( accountName = "Big Red Bucks", balance = it ) } - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) + Divider(color = GrayZero, thickness = 1.dp) accountTypeBalance.cityBucksBalance?.let { AccountBalanceRow( accountName = "City Bucks", balance = it ) } - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) + Divider(color = GrayZero, thickness = 1.dp) accountTypeBalance.laundryBalance?.let { AccountBalanceRow( accountName = "Laundry", @@ -337,25 +323,13 @@ private fun TransactionsHeader( placeholderText = "Search for transactions...", onCancelClicked = { setFilterText("") } ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .padding(horizontal = 16.dp) - .background(GrayZero, CircleShape) - ) + Divider(color = GrayZero, thickness = 1.dp, modifier = Modifier.padding(horizontal = 16.dp)) Text( text = "Past 30 Days", modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), style = EateryBlueTypography.h5 ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .padding(horizontal = 16.dp) - .background(GrayZero, CircleShape) - ) + Divider(color = GrayZero, thickness = 1.dp, modifier = Modifier.padding(horizontal = 16.dp)) } } @@ -490,12 +464,7 @@ private fun TransactionRow(transaction: Transaction, isMealSwipes: Boolean) { ) } - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) + Divider(color = GrayZero, thickness = 1.dp) } private fun formatDate(dateString: String): String { @@ -634,12 +603,10 @@ fun AccountTypesSelector( } } if (index != selectedPaymentMethod.lastIndex) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - .padding(horizontal = 16.dp) + Divider( + color = GrayZero, + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) ) } } From 2fa4e3929c6d0eb43de29033f43f42896f304fb9 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 02:50:24 -0400 Subject: [PATCH 115/126] Hoist date formatting logic --- .../eatery/ui/components/login/AccountPage.kt | 62 ++++++++----------- .../eatery/ui/screens/ProfileScreen.kt | 4 +- .../eatery/ui/viewmodels/LoginViewModel.kt | 33 +++++++++- 3 files changed, 60 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index e953b808..0f22a1ec 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -68,10 +68,9 @@ import com.cornellappdev.android.eatery.ui.theme.GrayFive import com.cornellappdev.android.eatery.ui.theme.GrayZero import com.cornellappdev.android.eatery.ui.theme.Green import com.cornellappdev.android.eatery.ui.theme.Red +import com.cornellappdev.android.eatery.ui.viewmodels.TransactionWithFormattedDate import com.cornellappdev.android.eatery.util.EateryPreview import kotlinx.coroutines.launch -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter import kotlin.math.abs @OptIn( @@ -83,7 +82,7 @@ fun AccountPage( accountFilter: TransactionAccountType, accountTypeBalance: AccountBalances, onSettingsClicked: () -> Unit, - filteredTransactions: List, + filteredTransactions: List, filterText: String, onQueryChanged: (String) -> Unit, updateAccountFilter: (TransactionAccountType) -> Unit @@ -155,7 +154,7 @@ private fun AccountPageContent( showBottomSheet: suspend () -> Unit, filterText: String, setFilterText: (String) -> Unit, - filteredTransactions: List, + filteredTransactions: List, setSheetContent: (BottomSheetContent) -> Unit ) { val innerListState = rememberLazyListState() @@ -224,9 +223,12 @@ private fun AccountPageContent( setFilterText ) } - items(items = filteredTransactions, key = { it.date + it.location + it.amount }) { + items( + items = filteredTransactions, + key = { it.transaction.date + it.transaction.location + it.transaction.amount }) { TransactionRow( - transaction = it, + transaction = it.transaction, + formattedDate = it.formattedDate, isMealSwipes = accountFilter == TransactionAccountType.MEAL_SWIPES ) } @@ -250,17 +252,23 @@ private fun AccountPagePreview() = EateryPreview { filterText = "", setFilterText = {}, filteredTransactions = listOf( - Transaction( - date = "2023-10-01T12:30:00.000Z", - location = "Cafe Jennie", - amount = 5.25, - transactionType = TransactionType.SPEND + TransactionWithFormattedDate( + transaction = Transaction( + date = "2023-10-01T12:30:00.000Z", + location = "Cafe Jennie", + amount = 5.25, + transactionType = TransactionType.SPEND + ), + formattedDate = "12:30 PM · Sunday, October 1" ), - Transaction( - date = "2023-10-02T14:00:00.000Z", - location = "Morrison Dining", - amount = 15.00, - transactionType = TransactionType.DEPOSIT + TransactionWithFormattedDate( + transaction = Transaction( + date = "2023-10-02T14:00:00.000Z", + location = "Morrison Dining", + amount = 15.00, + transactionType = TransactionType.DEPOSIT + ), + formattedDate = "2:00 PM · Monday, October 2" ) ), setSheetContent = {} @@ -416,8 +424,7 @@ private fun AccountPageHeader( } @Composable -private fun TransactionRow(transaction: Transaction, isMealSwipes: Boolean) { - val dateText = formatDate(transaction.date) +private fun TransactionRow(transaction: Transaction, formattedDate: String, isMealSwipes: Boolean) { Row( modifier = Modifier .height(64.dp) @@ -427,7 +434,7 @@ private fun TransactionRow(transaction: Transaction, isMealSwipes: Boolean) { Column(modifier = Modifier.weight(1f)) { Text(text = transaction.location, style = EateryBlueTypography.button) Text( - text = dateText, + text = formattedDate, style = EateryBlueTypography.subtitle2, color = GrayFive ) @@ -467,23 +474,6 @@ private fun TransactionRow(transaction: Transaction, isMealSwipes: Boolean) { Divider(color = GrayZero, thickness = 1.dp) } -private fun formatDate(dateString: String): String { - return try { - // Parse timezone-aware string like "2026-03-02T01:56:45.000+0000" - val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") - val zonedDateTime = ZonedDateTime.parse(dateString, inputFormatter) - - // Convert to system's local timezone - val localZonedDateTime = zonedDateTime.withZoneSameInstant(java.time.ZoneId.systemDefault()) - val localDateTime = localZonedDateTime.toLocalDateTime() - - val outputFormatter = DateTimeFormatter.ofPattern("h:mm a · EEEE, MMMM d") - outputFormatter.format(localDateTime) - } catch (e: Exception) { - e.printStackTrace() - "" - } -} private fun Double.epsilonEqual(other: Double): Boolean { val epsilon = 0.00001 diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index c16fd90c..e4b47b0c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -7,12 +7,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.cornellappdev.android.eatery.data.models.AccountBalances -import com.cornellappdev.android.eatery.data.models.Transaction import com.cornellappdev.android.eatery.data.models.TransactionAccountType import com.cornellappdev.android.eatery.ui.components.general.NetworkErrorToast import com.cornellappdev.android.eatery.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel +import com.cornellappdev.android.eatery.ui.viewmodels.TransactionWithFormattedDate @OptIn(ExperimentalAnimationApi::class, ExperimentalLifecycleComposeApi::class) @@ -68,7 +68,7 @@ private fun ProfileScreenContent( accountFilter: TransactionAccountType, filterText: String, onSettingsClicked: () -> Unit, - filteredTransactions: List, + filteredTransactions: List, onQueryChanged: (String) -> Unit, updateAccountFilter: (TransactionAccountType) -> Unit ) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index 86bdb312..04a05a37 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -22,6 +22,11 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter import javax.inject.Inject +data class TransactionWithFormattedDate( + val transaction: Transaction, + val formattedDate: String +) + @HiltViewModel class LoginViewModel @Inject constructor( private val userRepository: UserRepository, @@ -106,7 +111,7 @@ class LoginViewModel @Inject constructor( } - val filteredTransactionsFlow: Flow> = + val filteredTransactionsFlow: Flow> = combine( userRepository.loadedUser, _queryFlow, @@ -123,9 +128,35 @@ class LoginViewModel @Inject constructor( ) >= LocalDateTime.now().minusDays(30) val matchesQuery = transaction.location.lowercase().contains(query.lowercase()) matchesAccountType && pastThirtyDays && matchesQuery + }?.map { transaction -> + TransactionWithFormattedDate( + transaction = transaction, + formattedDate = formatDate(transaction.date) + ) } ?: emptyList() } + companion object { + fun formatDate(dateString: String): String { + return try { + // Parse timezone-aware string like "2026-03-02T01:56:45.000+0000" + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX") + val zonedDateTime = java.time.ZonedDateTime.parse(dateString, inputFormatter) + + // Convert to system's local timezone + val localZonedDateTime = + zonedDateTime.withZoneSameInstant(java.time.ZoneId.systemDefault()) + val localDateTime = localZonedDateTime.toLocalDateTime() + + val outputFormatter = DateTimeFormatter.ofPattern("h:mm a · EEEE, MMMM d") + outputFormatter.format(localDateTime) + } catch (e: Exception) { + e.printStackTrace() + "" + } + } + } + fun onLoginPressed() = updateLoginLoadingState(true) fun onLoginExited() = updateLoginLoadingState(false) From 5ae05e6b3714ac72a596a427c0a4b34d4cbdb406 Mon Sep 17 00:00:00 2001 From: Caleb Shim <74190657+caleb-bit@users.noreply.github.com> Date: Mon, 9 Mar 2026 02:53:16 -0400 Subject: [PATCH 116/126] Fix inefficient recomputation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../eatery/ui/components/login/AccountPage.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 0f22a1ec..4609e900 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -439,27 +439,23 @@ private fun TransactionRow(transaction: Transaction, formattedDate: String, isMe color = GrayFive ) } - var amtColor by remember { mutableStateOf(Color.Unspecified) } - var amtString by remember { mutableStateOf("$0.00") } - when { + val (amtString, amtColor) = when { transaction.transactionType == TransactionType.DEPOSIT -> { - amtString = "+$%.2f".format(transaction.amount) - amtColor = Green + "+$%.2f".format(transaction.amount) to Green } transaction.amount.epsilonEqual(0.0) -> { - amtString = "$0.00" - amtColor = Black + "$0.00" to Black } else -> { - amtString = if (isMealSwipes) { + val amt = if (isMealSwipes) { val numSwipes = transaction.amount.toInt() "-$numSwipes swipe" + (if (numSwipes > 1) "s" else "") } else { "-$%.2f".format(transaction.amount) } - amtColor = Red + amt to Red } } Text( From 3b52440d489281b0e754074faa046c7df9d24e1d Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 03:01:34 -0400 Subject: [PATCH 117/126] Prevent passing state down --- .../android/eatery/ui/components/login/AccountPage.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 4609e900..52107b8e 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt @@ -36,7 +36,6 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -158,8 +157,7 @@ private fun AccountPageContent( setSheetContent: (BottomSheetContent) -> Unit ) { val innerListState = rememberLazyListState() - val isFirstVisible = - remember { derivedStateOf { innerListState.firstVisibleItemIndex > 1 } } + val isFirstVisible by remember { derivedStateOf { innerListState.firstVisibleItemIndex > 1 } } Column( modifier = Modifier .fillMaxWidth() @@ -344,7 +342,7 @@ private fun TransactionsHeader( @Composable @OptIn(ExperimentalAnimationApi::class) private fun AccountPageHeader( - isFirstVisible: State, + isFirstVisible: Boolean, onSettingsClicked: () -> Unit ) { Column( @@ -355,7 +353,7 @@ private fun AccountPageHeader( .padding(bottom = 7.dp), ) { AnimatedContent( - targetState = isFirstVisible.value + targetState = isFirstVisible ) { isFirstVisible -> if (isFirstVisible) { Box( From 275a7093a12f727e5c0064039aa28a8c17d9a5e5 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 03:09:54 -0400 Subject: [PATCH 118/126] Use firstOrNull() instead of first() --- .../data/repositories/AuthTokenRepository.kt | 11 +++++------ .../eatery/data/repositories/UserRepository.kt | 14 +++++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt index f77581ef..4f607b1a 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt @@ -10,7 +10,7 @@ import com.cornellappdev.android.eatery.data.models.Result import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import retrofit2.HttpException import java.io.IOException import java.net.SocketTimeoutException @@ -59,7 +59,7 @@ class AuthTokenRepository @Inject constructor( suspend fun refreshTokens(): Result = safeRequest { val deviceId = getDeviceId() - val refreshToken = userPreferencesRepository.refreshTokenFlow.first() + val refreshToken = userPreferencesRepository.refreshTokenFlow.firstOrNull() ?: throw IllegalStateException("Refresh token not available") val tokens = networkApi.refreshToken( RefreshRequest( @@ -83,7 +83,7 @@ class AuthTokenRepository @Inject constructor( suspend fun getAccessToken(): String = prependBearer( - userPreferencesRepository.accessTokenFlow.first() + userPreferencesRepository.accessTokenFlow.firstOrNull() ?: throw IllegalStateException("Access token not available") ) @@ -111,9 +111,9 @@ class AuthTokenRepository @Inject constructor( } } - suspend fun getSessionId(): String = userPreferencesRepository.sessionIdFlow.first() + suspend fun getSessionId(): String = userPreferencesRepository.sessionIdFlow.firstOrNull() ?: "" - suspend fun getPin(): Int = userPreferencesRepository.pinFlow.first() + suspend fun getPin(): Int = userPreferencesRepository.pinFlow.firstOrNull() ?: 0 suspend fun clearAuthTokens() { userPreferencesRepository.setSessionId("") @@ -166,4 +166,3 @@ class AuthTokenRepository @Inject constructor( private fun prependBearer(str: String) = "Bearer $str" } - diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index bc7c8150..7bb4c990 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -13,7 +13,7 @@ import com.cornellappdev.android.eatery.data.models.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import retrofit2.HttpException import java.io.IOException @@ -52,8 +52,10 @@ class UserRepository @Inject constructor( suspend fun updateFavorites(): Result { if (useLocalFavorites) { - _favoriteEateriesFlow.value = userPreferencesRepository.favoriteEateryNamesFlow.first() - _favoriteItemsFlow.value = userPreferencesRepository.favoriteItemNamesFlow.first() + _favoriteEateriesFlow.value = + userPreferencesRepository.favoriteEateryNamesFlow.firstOrNull() ?: emptyList() + _favoriteItemsFlow.value = + userPreferencesRepository.favoriteItemNamesFlow.firstOrNull() ?: emptyList() return Result.Success(Unit) } @@ -188,7 +190,8 @@ class UserRepository @Inject constructor( suspend fun setIsLoggedIn(isLoggedIn: Boolean) = userPreferencesRepository.setIsLoggedIn(isLoggedIn) - suspend fun isLoggedIn(): Boolean = userPreferencesRepository.isLoggedInFlow.first() + suspend fun isLoggedIn(): Boolean = + userPreferencesRepository.isLoggedInFlow.firstOrNull() ?: false suspend fun logout() { _loadedUser.value = null @@ -196,7 +199,8 @@ class UserRepository @Inject constructor( userPreferencesRepository.setIsLoggedIn(false) } - suspend fun hasOnboarded(): Boolean = userPreferencesRepository.hasOnboardedFlow.first() + suspend fun hasOnboarded(): Boolean = + userPreferencesRepository.hasOnboardedFlow.firstOrNull() ?: false /** * Tries to make the given request, and if it fails, refreshes tokens and tries again. From aba118da07ba59261cf9b567b80ef50043befd8f Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 03:18:48 -0400 Subject: [PATCH 119/126] Fix logout behavior --- .../android/eatery/data/repositories/AuthTokenRepository.kt | 6 +----- .../android/eatery/data/repositories/UserRepository.kt | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt index 4f607b1a..a5bd6b54 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt @@ -115,11 +115,7 @@ class AuthTokenRepository @Inject constructor( suspend fun getPin(): Int = userPreferencesRepository.pinFlow.firstOrNull() ?: 0 - suspend fun clearAuthTokens() { - userPreferencesRepository.setSessionId("") - userPreferencesRepository.setAccessToken("") - userPreferencesRepository.setRefreshToken("") - } + suspend fun clearSessionId() = userPreferencesRepository.setSessionId("") /** * Converts exceptions into appropriate [NetworkError] types. diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 7bb4c990..5274ab45 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -195,7 +195,7 @@ class UserRepository @Inject constructor( suspend fun logout() { _loadedUser.value = null - authTokenRepository.clearAuthTokens() + authTokenRepository.clearSessionId() userPreferencesRepository.setIsLoggedIn(false) } From 6ea6e01d58904737dbbfd8ea0b25c299b3c930cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:27:09 +0000 Subject: [PATCH 120/126] Initial plan From 4b3725d0468c10a2e91c3496bf9d9d5a4b3e6e54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:34:05 +0000 Subject: [PATCH 121/126] Encrypt sensitive auth credentials stored in DataStore at rest Co-authored-by: caleb-bit <74190657+caleb-bit@users.noreply.github.com> --- .../repositories/UserPreferencesRepository.kt | 97 ++++++++++++++++--- .../android/eatery/util/Encryption.kt | 32 +++--- app/src/main/proto/user_prefs.proto | 3 + 3 files changed, 104 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index d8552002..119e515c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -2,6 +2,8 @@ package com.cornellappdev.android.eatery.data.repositories import androidx.datastore.core.DataStore import com.cornellappdev.android.eatery.UserPreferences +import com.cornellappdev.android.eatery.util.decryptData +import com.cornellappdev.android.eatery.util.encryptData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.util.UUID @@ -14,6 +16,13 @@ class UserPreferencesRepository @Inject constructor( ) { companion object { private const val MAX_RECENT_SEARCHES = 20 + + // Android KeyStore key aliases for sensitive credential fields + private const val ALIAS_ACCESS_TOKEN = "eatery_access_token" + private const val ALIAS_REFRESH_TOKEN = "eatery_refresh_token" + private const val ALIAS_SESSION_ID = "eatery_session_id" + private const val ALIAS_DEVICE_ID = "eatery_device_id" + private const val ALIAS_PIN = "eatery_pin" } private val userPreferencesFlow: Flow = userPreferencesStore.data @@ -22,11 +31,48 @@ class UserPreferencesRepository @Inject constructor( val notificationFlowCompletedFlow: Flow = userPreferencesFlow.map { it.notificationFlowCompleted } val analyticsDisabledFlow: Flow = userPreferencesFlow.map { it.analyticsDisabled } - val accessTokenFlow: Flow = userPreferencesFlow.map { it.accessToken.nullIfEmpty() } - val refreshTokenFlow: Flow = userPreferencesFlow.map { it.refreshToken.nullIfEmpty() } + + /** + * Emits the decrypted access token, or null if absent or decryption fails. + * Tokens are encrypted at rest using AES/GCM via the Android KeyStore. + */ + val accessTokenFlow: Flow = userPreferencesFlow.map { prefs -> + val stored = prefs.accessToken.nullIfEmpty() ?: return@map null + runCatching { decryptData(ALIAS_ACCESS_TOKEN, stored) }.getOrNull() + } + + /** + * Emits the decrypted refresh token, or null if absent or decryption fails. + */ + val refreshTokenFlow: Flow = userPreferencesFlow.map { prefs -> + val stored = prefs.refreshToken.nullIfEmpty() ?: return@map null + runCatching { decryptData(ALIAS_REFRESH_TOKEN, stored) }.getOrNull() + } + val isLoggedInFlow: Flow = userPreferencesFlow.map { it.isLoggedIn } - val pinFlow: Flow = userPreferencesFlow.map { it.pin } - val sessionIdFlow: Flow = userPreferencesFlow.map { it.sessionId } + + /** + * Emits the decrypted PIN. Prefers the encrypted [UserPreferences.encryptedPin] field; + * falls back to the legacy plaintext [UserPreferences.pin] field for migration or on + * decryption failure. + */ + val pinFlow: Flow = userPreferencesFlow.map { prefs -> + val stored = prefs.encryptedPin.nullIfEmpty() + if (stored != null) { + runCatching { decryptData(ALIAS_PIN, stored).toInt() }.getOrElse { prefs.pin } + } else { + prefs.pin + } + } + + /** + * Emits the decrypted session ID, or an empty string if absent or decryption fails. + */ + val sessionIdFlow: Flow = userPreferencesFlow.map { prefs -> + val stored = prefs.sessionId.nullIfEmpty() ?: return@map "" + runCatching { decryptData(ALIAS_SESSION_ID, stored) }.getOrElse { "" } + } + val favoriteEateryNamesFlow: Flow> = userPreferencesFlow.map { it.favoriteEateryNamesList } val favoriteItemNamesFlow: Flow> = @@ -90,19 +136,30 @@ class UserPreferencesRepository @Inject constructor( } // This approach avoids race conditions by performing get and set inside - // updateData which is atomic + // updateData which is atomic. + // The device ID is encrypted using the Android KeyStore. Legacy unencrypted UUIDs + // (stored by a previous app version) are re-encrypted transparently on first access. suspend fun getOrCreateDeviceId(): String { var resolvedDeviceId: String? = null userPreferencesStore.updateData { currentPreferences -> - val existingDeviceId = currentPreferences.deviceId.nullIfEmpty() - if (existingDeviceId != null) { - resolvedDeviceId = existingDeviceId - currentPreferences + val existingRaw = currentPreferences.deviceId.nullIfEmpty() + if (existingRaw != null) { + val decryptResult = runCatching { decryptData(ALIAS_DEVICE_ID, existingRaw) } + if (decryptResult.isSuccess) { + resolvedDeviceId = decryptResult.getOrNull() + currentPreferences + } else { + // Legacy plaintext UUID – re-encrypt it and update the store + resolvedDeviceId = existingRaw + currentPreferences.toBuilder() + .setDeviceId(encryptData(ALIAS_DEVICE_ID, existingRaw)) + .build() + } } else { val newDeviceId = UUID.randomUUID().toString() resolvedDeviceId = newDeviceId currentPreferences.toBuilder() - .setDeviceId(newDeviceId) + .setDeviceId(encryptData(ALIAS_DEVICE_ID, newDeviceId)) .build() } } @@ -111,23 +168,35 @@ class UserPreferencesRepository @Inject constructor( private fun String?.nullIfEmpty(): String? = if (this.isNullOrEmpty()) null else this + /** Encrypts [accessToken] before persisting. Pass an empty string to clear the value. */ suspend fun setAccessToken(accessToken: String) { - setPref { setAccessToken(accessToken) } + val toStore = if (accessToken.isEmpty()) "" else encryptData(ALIAS_ACCESS_TOKEN, accessToken) + setPref { setAccessToken(toStore) } } + /** Encrypts [refreshToken] before persisting. Pass an empty string to clear the value. */ suspend fun setRefreshToken(refreshToken: String) { - setPref { setRefreshToken(refreshToken) } + val toStore = + if (refreshToken.isEmpty()) "" else encryptData(ALIAS_REFRESH_TOKEN, refreshToken) + setPref { setRefreshToken(toStore) } } suspend fun setIsLoggedIn(loggedIn: Boolean) = setPref { setIsLoggedIn(loggedIn) } + /** + * Encrypts [pin] and stores it in the [UserPreferences.encryptedPin] field. + * Clears the legacy plaintext [UserPreferences.pin] field at the same time. + */ suspend fun setPin(pin: Int) { - setPref { setPin(pin) } + val toStore = encryptData(ALIAS_PIN, pin.toString()) + setPref { setEncryptedPin(toStore).setPin(0) } } + /** Encrypts [sessionId] before persisting. Pass an empty string to clear the value. */ suspend fun setSessionId(sessionId: String) { - setPref { setSessionId(sessionId) } + val toStore = if (sessionId.isEmpty()) "" else encryptData(ALIAS_SESSION_ID, sessionId) + setPref { setSessionId(toStore) } } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/util/Encryption.kt b/app/src/main/java/com/cornellappdev/android/eatery/util/Encryption.kt index 005f4e07..55df6408 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/util/Encryption.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/util/Encryption.kt @@ -9,7 +9,6 @@ import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -private val cipher = Cipher.getInstance("AES/GCM/NoPadding") private val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } @@ -18,26 +17,30 @@ private val utf8 by lazy { Charsets.UTF_8 } -private fun makeSecretKey(alias: String): SecretKey { - return keyGenerator.apply { - init( - KeyGenParameterSpec.Builder( - alias, - KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT +private fun getOrCreateSecretKey(alias: String): SecretKey { + return if (keyStore.containsAlias(alias)) { + (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey + } else { + keyGenerator.apply { + init( + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - ) - }.generateKey() - + }.generateKey() + } } private fun getSecretKey(alias: String) = (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey fun encryptData(alias: String, data: String): String { - cipher.init(Cipher.ENCRYPT_MODE, makeSecretKey(alias)) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey(alias)) // Encodes the data to string, then returns. return Base64.encodeToString( @@ -47,6 +50,7 @@ fun encryptData(alias: String, data: String): String { } fun decryptData(alias: String, data: String): String { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") // Takes in data, gets the IV as a ByteArray. val ivString = Base64.decode(data.substring(0, data.indexOf("||")), Base64.NO_WRAP) diff --git a/app/src/main/proto/user_prefs.proto b/app/src/main/proto/user_prefs.proto index 787ccd71..fa2c775c 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -30,6 +30,9 @@ message UserPreferences { // delete once no longer local repeated string favoriteEateryNames = 15; + // Encrypted PIN stored as a Base64-encoded string (replaces the plaintext int32 pin field). + string encryptedPin = 16; + // repeated int32 recentSearches = 2; // string username = 3; // // Must be encrypted / decrypted. From 8e511040a6b7eb4732f5e556b2b95ce46975ae50 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 03:46:58 -0400 Subject: [PATCH 122/126] Implement OkHttp interceptor --- .../android/eatery/data/AuthInterceptor.kt | 73 +++++++++++++++++++ .../android/eatery/data/NetworkingApi.kt | 14 +--- .../data/repositories/AuthTokenRepository.kt | 2 - .../data/repositories/UserRepository.kt | 9 +-- .../android/eatery/di/NetworkingModule.kt | 4 +- 5 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/android/eatery/data/AuthInterceptor.kt diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/AuthInterceptor.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/AuthInterceptor.kt new file mode 100644 index 00000000..9517f938 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/AuthInterceptor.kt @@ -0,0 +1,73 @@ +package com.cornellappdev.android.eatery.data + +import com.cornellappdev.android.eatery.data.repositories.AuthTokenRepository +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Provider + +/** + * OkHttp interceptor that automatically adds Bearer token to authenticated requests. + * Also handles token refresh on 401 responses. + * + * Uses Provider to avoid circular dependency + */ +class AuthInterceptor @Inject constructor( + private val authTokenRepositoryProvider: Provider +) : Interceptor { + + companion object { + private val PUBLIC_ENDPOINTS = setOf( + "/eateries/", + "/auth/verify-token", + "/auth/refresh-token" + ) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (!isPublicEndpoint(request)) { + val requestWithToken = addTokenToRequest(request) + var response = chain.proceed(requestWithToken) + + if (response.code == 401) { + response.close() + try { + runBlocking { + authTokenRepositoryProvider.get().refreshTokens() + } + val retryRequest = addTokenToRequest(request) + response = chain.proceed(retryRequest) + } catch (_: Exception) { + return chain.proceed(request) + } + } + + return response + } + + return chain.proceed(request) + } + + private fun addTokenToRequest(request: Request): Request { + return try { + val token = runBlocking { + authTokenRepositoryProvider.get().getAccessToken() + } + request.newBuilder() + .header("Authorization", token) + .build() + } catch (_: Exception) { + request + } + } + + private fun isPublicEndpoint(request: Request): Boolean { + val path = request.url.encodedPath + return PUBLIC_ENDPOINTS.any { path.startsWith(it) } + } +} + diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index a5c61c5d..43568e12 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -18,7 +18,6 @@ import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.HTTP -import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path @@ -56,60 +55,49 @@ interface NetworkApi { @POST("/users/fcm-token") suspend fun enableNotifications( - @Header("Authorization") accessToken: String, @Body token: FcmToken ) @DELETE("/users/fcm-token") suspend fun disableNotifications( - @Header("Authorization") accessToken: String, @Body token: FcmToken ) @POST("/users/favorites/items") suspend fun addFavoriteItem( - @Header("Authorization") accessToken: String, @Body item: FavoriteItem ) @HTTP(method = "DELETE", path = "/users/favorites/items", hasBody = true) suspend fun deleteFavoriteItem( - @Header("Authorization") accessToken: String, @Body item: FavoriteItem ) @POST("/users/favorites/eateries") suspend fun addFavoriteEatery( - @Header("Authorization") accessToken: String, @Body eatery: FavoriteEatery ) @HTTP(method = "DELETE", path = "/users/favorites/eateries", hasBody = true) suspend fun deleteFavoriteEatery( - @Header("Authorization") accessToken: String, @Body eatery: FavoriteEatery ) @POST("/auth/get/authorize") suspend fun authorizeUser( - @Header("Authorization") accessToken: String, @Body loginRequest: LoginRequest ) @POST("/auth/get/refresh") suspend fun refreshAuthorizedUser( - @Header("Authorization") accessToken: String, @Body loginPIN: LoginPIN ): SessionID @POST("/financials") suspend fun getFinancials( - @Header("Authorization") accessToken: String, @Body sessionId: SessionID ): Financials @GET("/users/favorites/matches") - suspend fun getFavoriteMatches( - @Header("Authorization") accessToken: String, - ): List + suspend fun getFavoriteMatches(): List } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt index a5bd6b54..55a6c109 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/AuthTokenRepository.kt @@ -93,7 +93,6 @@ class AuthTokenRepository @Inject constructor( userPreferencesRepository.setPin(pin) return tryRequestWithResult { networkApi.authorizeUser( - accessToken = getAccessToken(), loginRequest = LoginRequest(pin.toString(), sessionId) ) } @@ -101,7 +100,6 @@ class AuthTokenRepository @Inject constructor( suspend fun refreshLogin(pin: Int): Result = tryRequestWithResult { val newSessionId = networkApi.refreshAuthorizedUser( - accessToken = getAccessToken(), loginPIN = LoginPIN(pin.toString()) ).sessionId if (newSessionId == null) { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 5274ab45..b7b3dc4c 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -60,8 +60,7 @@ class UserRepository @Inject constructor( } return tryRequestWithResult { - val accessPhrase = authTokenRepository.getAccessToken() - val matches = networkApi.getFavoriteMatches(accessToken = accessPhrase) + val matches = networkApi.getFavoriteMatches() _favoriteEateriesFlow.value = matches.mapNotNull { it.eateryName } _favoriteItemsFlow.value = run { val items: List = @@ -92,7 +91,6 @@ class UserRepository @Inject constructor( return tryRequestWithResult { networkApi.addFavoriteItem( - accessToken = authTokenRepository.getAccessToken(), item = FavoriteItem(item = name) ) _favoriteItemsFlow.update { currentItems -> @@ -112,7 +110,6 @@ class UserRepository @Inject constructor( return tryRequestWithResult { networkApi.deleteFavoriteItem( - accessToken = authTokenRepository.getAccessToken(), item = FavoriteItem(name) ) _favoriteItemsFlow.update { currentItems -> @@ -132,7 +129,6 @@ class UserRepository @Inject constructor( return tryRequestWithResult { networkApi.addFavoriteEatery( - accessToken = authTokenRepository.getAccessToken(), eatery = FavoriteEatery(id), ) _favoriteEateriesFlow.update { currentEateries -> @@ -152,7 +148,6 @@ class UserRepository @Inject constructor( return tryRequestWithResult { networkApi.deleteFavoriteEatery( - accessToken = authTokenRepository.getAccessToken(), eatery = FavoriteEatery(id) ) _favoriteEateriesFlow.update { currentEateries -> @@ -166,14 +161,12 @@ class UserRepository @Inject constructor( var financials: Financials try { financials = networkApi.getFinancials( - accessToken = authTokenRepository.getAccessToken(), sessionId = SessionID(authTokenRepository.getSessionId()) ) } catch (_: Exception) { val pin = authTokenRepository.getPin() authTokenRepository.refreshLogin(pin = pin) financials = networkApi.getFinancials( - accessToken = authTokenRepository.getAccessToken(), sessionId = SessionID(authTokenRepository.getSessionId()) ) } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/di/NetworkingModule.kt b/app/src/main/java/com/cornellappdev/android/eatery/di/NetworkingModule.kt index 8cc634dd..ce6479cc 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/di/NetworkingModule.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/di/NetworkingModule.kt @@ -3,6 +3,7 @@ package com.cornellappdev.android.eatery.di import android.util.Log import com.cornellappdev.android.eatery.BuildConfig import com.cornellappdev.android.eatery.data.AccountTypeAdapter +import com.cornellappdev.android.eatery.data.AuthInterceptor import com.cornellappdev.android.eatery.data.DateAdapter import com.cornellappdev.android.eatery.data.DateTimeAdapter import com.cornellappdev.android.eatery.data.NetworkApi @@ -29,7 +30,7 @@ import javax.inject.Singleton object NetworkModule { @Singleton @Provides - fun provideHttpClient(): OkHttpClient { + fun provideHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { val logging = HttpLoggingInterceptor { message -> Log.d("NetworkRequest", message) } logging.level = (HttpLoggingInterceptor.Level.BODY) @@ -37,6 +38,7 @@ object NetworkModule { .Builder() .readTimeout(200, TimeUnit.SECONDS) .connectTimeout(200, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) .addInterceptor(logging) .build() } From aca978ca15e405c6471356dff96fb1e35106f3a2 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 04:10:38 -0400 Subject: [PATCH 123/126] Extract functionality --- .../repositories/UserPreferencesRepository.kt | 64 ++++++++++--------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 119e515c..c83cf330 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -37,16 +37,14 @@ class UserPreferencesRepository @Inject constructor( * Tokens are encrypted at rest using AES/GCM via the Android KeyStore. */ val accessTokenFlow: Flow = userPreferencesFlow.map { prefs -> - val stored = prefs.accessToken.nullIfEmpty() ?: return@map null - runCatching { decryptData(ALIAS_ACCESS_TOKEN, stored) }.getOrNull() + decryptOrNull(ALIAS_ACCESS_TOKEN, prefs.accessToken) } /** * Emits the decrypted refresh token, or null if absent or decryption fails. */ val refreshTokenFlow: Flow = userPreferencesFlow.map { prefs -> - val stored = prefs.refreshToken.nullIfEmpty() ?: return@map null - runCatching { decryptData(ALIAS_REFRESH_TOKEN, stored) }.getOrNull() + decryptOrNull(ALIAS_REFRESH_TOKEN, prefs.refreshToken) } val isLoggedInFlow: Flow = userPreferencesFlow.map { it.isLoggedIn } @@ -57,20 +55,15 @@ class UserPreferencesRepository @Inject constructor( * decryption failure. */ val pinFlow: Flow = userPreferencesFlow.map { prefs -> - val stored = prefs.encryptedPin.nullIfEmpty() - if (stored != null) { - runCatching { decryptData(ALIAS_PIN, stored).toInt() }.getOrElse { prefs.pin } - } else { - prefs.pin - } + decryptOrDefault(ALIAS_PIN, prefs.encryptedPin) { prefs.pin.toString() } + .toIntOrNull() ?: prefs.pin } /** * Emits the decrypted session ID, or an empty string if absent or decryption fails. */ val sessionIdFlow: Flow = userPreferencesFlow.map { prefs -> - val stored = prefs.sessionId.nullIfEmpty() ?: return@map "" - runCatching { decryptData(ALIAS_SESSION_ID, stored) }.getOrElse { "" } + decryptOrDefault(ALIAS_SESSION_ID, prefs.sessionId) { "" } } val favoriteEateryNamesFlow: Flow> = @@ -135,8 +128,6 @@ class UserPreferencesRepository @Inject constructor( } } - // This approach avoids race conditions by performing get and set inside - // updateData which is atomic. // The device ID is encrypted using the Android KeyStore. Legacy unencrypted UUIDs // (stored by a previous app version) are re-encrypted transparently on first access. suspend fun getOrCreateDeviceId(): String { @@ -144,41 +135,37 @@ class UserPreferencesRepository @Inject constructor( userPreferencesStore.updateData { currentPreferences -> val existingRaw = currentPreferences.deviceId.nullIfEmpty() if (existingRaw != null) { - val decryptResult = runCatching { decryptData(ALIAS_DEVICE_ID, existingRaw) } - if (decryptResult.isSuccess) { - resolvedDeviceId = decryptResult.getOrNull() + val decrypted = decryptOrNull(ALIAS_DEVICE_ID, existingRaw) + if (decrypted != null) { + resolvedDeviceId = decrypted currentPreferences } else { - // Legacy plaintext UUID – re-encrypt it and update the store + // Legacy plaintext UUID - re-encrypt it and update the store. resolvedDeviceId = existingRaw currentPreferences.toBuilder() - .setDeviceId(encryptData(ALIAS_DEVICE_ID, existingRaw)) + .setDeviceId(encryptOrEmpty(ALIAS_DEVICE_ID, existingRaw)) .build() } } else { val newDeviceId = UUID.randomUUID().toString() resolvedDeviceId = newDeviceId currentPreferences.toBuilder() - .setDeviceId(encryptData(ALIAS_DEVICE_ID, newDeviceId)) + .setDeviceId(encryptOrEmpty(ALIAS_DEVICE_ID, newDeviceId)) .build() } } return checkNotNull(resolvedDeviceId) } - private fun String?.nullIfEmpty(): String? = if (this.isNullOrEmpty()) null else this /** Encrypts [accessToken] before persisting. Pass an empty string to clear the value. */ suspend fun setAccessToken(accessToken: String) { - val toStore = if (accessToken.isEmpty()) "" else encryptData(ALIAS_ACCESS_TOKEN, accessToken) - setPref { setAccessToken(toStore) } + setPref { setAccessToken(encryptOrEmpty(ALIAS_ACCESS_TOKEN, accessToken)) } } /** Encrypts [refreshToken] before persisting. Pass an empty string to clear the value. */ suspend fun setRefreshToken(refreshToken: String) { - val toStore = - if (refreshToken.isEmpty()) "" else encryptData(ALIAS_REFRESH_TOKEN, refreshToken) - setPref { setRefreshToken(toStore) } + setPref { setRefreshToken(encryptOrEmpty(ALIAS_REFRESH_TOKEN, refreshToken)) } } suspend fun setIsLoggedIn(loggedIn: Boolean) = setPref { @@ -190,13 +177,32 @@ class UserPreferencesRepository @Inject constructor( * Clears the legacy plaintext [UserPreferences.pin] field at the same time. */ suspend fun setPin(pin: Int) { - val toStore = encryptData(ALIAS_PIN, pin.toString()) + val toStore = encryptOrEmpty(ALIAS_PIN, pin.toString()) setPref { setEncryptedPin(toStore).setPin(0) } } /** Encrypts [sessionId] before persisting. Pass an empty string to clear the value. */ suspend fun setSessionId(sessionId: String) { - val toStore = if (sessionId.isEmpty()) "" else encryptData(ALIAS_SESSION_ID, sessionId) - setPref { setSessionId(toStore) } + setPref { setSessionId(encryptOrEmpty(ALIAS_SESSION_ID, sessionId)) } + } + + private fun String?.nullIfEmpty(): String? = if (this.isNullOrEmpty()) null else this + + private fun decryptOrNull(alias: String, encryptedValue: String?): String? { + val stored = encryptedValue.nullIfEmpty() ?: return null + return runCatching { decryptData(alias, stored) }.getOrNull() + } + + private fun decryptOrDefault( + alias: String, + encryptedValue: String?, + defaultValue: () -> String, + ): String { + val stored = encryptedValue.nullIfEmpty() ?: return defaultValue() + return runCatching { decryptData(alias, stored) }.getOrElse { defaultValue() } + } + + private fun encryptOrEmpty(alias: String, rawValue: String): String { + return if (rawValue.isEmpty()) "" else encryptData(alias, rawValue) } } From 4ebe6e53525776b7b247c51db7c268f926429839 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 04:25:40 -0400 Subject: [PATCH 124/126] Remove unnecessary legacy handling --- .../repositories/UserPreferencesRepository.kt | 32 ++++++------------- app/src/main/proto/user_prefs.proto | 7 ++-- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index c83cf330..7c96894d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -50,13 +50,11 @@ class UserPreferencesRepository @Inject constructor( val isLoggedInFlow: Flow = userPreferencesFlow.map { it.isLoggedIn } /** - * Emits the decrypted PIN. Prefers the encrypted [UserPreferences.encryptedPin] field; - * falls back to the legacy plaintext [UserPreferences.pin] field for migration or on - * decryption failure. + * Emits the decrypted PIN from [UserPreferences.encryptedPin]. + * Emits 0 if absent, decryption fails, or the decrypted value is invalid. */ val pinFlow: Flow = userPreferencesFlow.map { prefs -> - decryptOrDefault(ALIAS_PIN, prefs.encryptedPin) { prefs.pin.toString() } - .toIntOrNull() ?: prefs.pin + decryptOrDefault(ALIAS_PIN, prefs.encryptedPin) { "0" }.toIntOrNull() ?: 0 } /** @@ -128,24 +126,15 @@ class UserPreferencesRepository @Inject constructor( } } - // The device ID is encrypted using the Android KeyStore. Legacy unencrypted UUIDs - // (stored by a previous app version) are re-encrypted transparently on first access. + // The device ID is encrypted using the Android KeyStore. suspend fun getOrCreateDeviceId(): String { var resolvedDeviceId: String? = null userPreferencesStore.updateData { currentPreferences -> val existingRaw = currentPreferences.deviceId.nullIfEmpty() - if (existingRaw != null) { - val decrypted = decryptOrNull(ALIAS_DEVICE_ID, existingRaw) - if (decrypted != null) { - resolvedDeviceId = decrypted - currentPreferences - } else { - // Legacy plaintext UUID - re-encrypt it and update the store. - resolvedDeviceId = existingRaw - currentPreferences.toBuilder() - .setDeviceId(encryptOrEmpty(ALIAS_DEVICE_ID, existingRaw)) - .build() - } + val existingDecrypted = existingRaw?.let { decryptOrNull(ALIAS_DEVICE_ID, it) } + if (existingDecrypted != null) { + resolvedDeviceId = existingDecrypted + currentPreferences } else { val newDeviceId = UUID.randomUUID().toString() resolvedDeviceId = newDeviceId @@ -173,12 +162,11 @@ class UserPreferencesRepository @Inject constructor( } /** - * Encrypts [pin] and stores it in the [UserPreferences.encryptedPin] field. - * Clears the legacy plaintext [UserPreferences.pin] field at the same time. + * Encrypts [pin] and stores it in [UserPreferences.encryptedPin]. */ suspend fun setPin(pin: Int) { val toStore = encryptOrEmpty(ALIAS_PIN, pin.toString()) - setPref { setEncryptedPin(toStore).setPin(0) } + setPref { setEncryptedPin(toStore) } } /** Encrypts [sessionId] before persisting. Pass an empty string to clear the value. */ diff --git a/app/src/main/proto/user_prefs.proto b/app/src/main/proto/user_prefs.proto index fa2c775c..2a9270c5 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -25,13 +25,12 @@ message UserPreferences { string deviceId = 11; string accessToken = 12; string refreshToken = 13; - int32 pin = 14; // delete once no longer local - repeated string favoriteEateryNames = 15; + repeated string favoriteEateryNames = 14; - // Encrypted PIN stored as a Base64-encoded string (replaces the plaintext int32 pin field). - string encryptedPin = 16; + // Encrypted PIN stored as a Base64-encoded string. + string encryptedPin = 15; // repeated int32 recentSearches = 2; // string username = 3; From 8ae1fe7cbf99ad43298747a46a269a32ad25a558 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 04:37:18 -0400 Subject: [PATCH 125/126] Ensure backward compatibility in user_prefs --- app/src/main/proto/user_prefs.proto | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/app/src/main/proto/user_prefs.proto b/app/src/main/proto/user_prefs.proto index 2a9270c5..0e7e52e5 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -11,26 +11,25 @@ message UserPreferences { map favorites = 3; bool isLoggedIn = 4; - string sessionId = 5; - repeated int32 recentSearches = 6; + repeated int32 recentSearches = 7; + bool analyticsDisabled = 8; + Date lastShowedRatingPopup = 9; + int32 minDaysBetweenRatingShow = 10; + map itemFavorites = 11; - bool analyticsDisabled = 7; + string sessionId = 12; + string deviceId = 13; + string accessToken = 14; + string refreshToken = 15; - Date lastShowedRatingPopup = 8; - - int32 minDaysBetweenRatingShow = 9; - - map itemFavorites = 10; - - string deviceId = 11; - string accessToken = 12; - string refreshToken = 13; + // Legacy tags + reserved 5, 6; // delete once no longer local - repeated string favoriteEateryNames = 14; + repeated string favoriteEateryNames = 16; // Encrypted PIN stored as a Base64-encoded string. - string encryptedPin = 15; + string encryptedPin = 17; // repeated int32 recentSearches = 2; // string username = 3; From c3cca7755cf75d25cd1523fc23fb61960f0a2e46 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Mon, 9 Mar 2026 06:29:15 -0400 Subject: [PATCH 126/126] Update version number --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 18c103c1..8f3193de 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.cornellappdev.android.eatery" minSdk 28 targetSdk 34 - versionCode 75 - versionName "1.2.1-cmpmenus-url" + versionCode 76 + versionName "1.3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {