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 09699820..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 @@ -1,3 +1,5 @@ +@file:Suppress("unused") + package com.cornellappdev.android.eatery.data import com.cornellappdev.android.eatery.data.models.AccountType @@ -85,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 } } @@ -116,7 +113,7 @@ class AccountTypeAdapter { "brb" } - AccountType.CITYBUCKS -> { + AccountType.CITY_BUCKS -> { "city bucks" } @@ -124,10 +121,6 @@ class AccountTypeAdapter { "laundry" } - AccountType.MEALSWIPES -> { - "meal plan" - } - else -> { "other" } @@ -155,7 +148,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 6d597e7b..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,57 +1,113 @@ 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.AuthTokens +import com.cornellappdev.android.eatery.data.models.DeviceId 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.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.GetApiTransactionHistoryParams -import com.cornellappdev.android.eatery.data.models.GetApiUserParams +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.TransactionsResponse -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 -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/") + @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 - - @GET("/event") - suspend fun fetchEvents(): ApiResponse> - - @POST("/report/") suspend fun sendReport( @Body report: ReportSendBody ): GetApiResponse + + /** + * 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") accessToken: String, + @Body loginRequest: LoginRequest + ) + + @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/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..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 @@ -19,26 +18,33 @@ 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) 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, ) { fun getWalkTimes(): Int? { val currentLocation = LocationHandler.currentLocation.value @@ -56,29 +62,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() @@ -86,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) } @@ -111,18 +94,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 @@ -136,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() } @@ -153,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 } @@ -168,16 +140,16 @@ 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.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 ) @@ -207,9 +179,8 @@ 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) + currentDay.dayOfYear == event.startTimestamp?.dayOfYear && meal.text.contains(event.type) } } @@ -219,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)) } } @@ -231,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"))}" } @@ -239,16 +210,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]. */ @@ -260,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 @@ -271,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 { @@ -290,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) @@ -297,14 +274,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 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) { @@ -312,7 +287,7 @@ data class Eatery( } } - DayOfWeek.values().forEach { dayOfWeek -> + DayOfWeek.entries.forEach { dayOfWeek -> dailyHours.computeIfAbsent(dayOfWeek) { mutableListOf("Closed") } } @@ -329,7 +304,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 +365,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 } @@ -437,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 697dab21..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 @@ -1,74 +1,163 @@ +@file:Suppress("AddExplicitTargetToParameterAnnotation") + 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 = "id") val id: 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, - var accounts: List? = null, - var transactions: List? = listOf() + @Json(name = "favorite_eateries") val favoriteEateries: List = emptyList(), + @Json(name = "favorite_items") val favoriteItems: List = emptyList(), + @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 = "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 AccountsResponse( - @Json(name = "accounts") val accounts: List? = null +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, + @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 TransactionsResponse( - @Json(name = "totalCount") val totalCount: Int? = null, - @Json(name = "returnCapped") val returnCapped: Boolean? = null, - @Json(name = "transactions") val transactions: List? = null +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) { 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/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/EateryRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt index 05c0e620..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 @@ -1,10 +1,9 @@ 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 com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse.Success import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -25,12 +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 suspend fun getAllEvents(): ApiResponse> = - networkApi.fetchEvents() - private val _eateryFlow: MutableStateFlow>> = MutableStateFlow(EateryApiResponse.Pending) @@ -39,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. */ @@ -58,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 } @@ -75,10 +55,10 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { CoroutineScope(Dispatchers.IO).launch { try { val eateries = getAllEateries() - _eateryFlow.value = EateryApiResponse.Success(eateries) - eateryApiCache.update { map -> + _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) { @@ -90,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 @@ -117,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 87ecbb7d..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,40 +2,23 @@ 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 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 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()) @@ -52,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() @@ -119,12 +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) + private suspend fun setPref(setter: UserPreferences.Builder.() -> UserPreferences.Builder) { + userPreferencesStore.updateData { currentPreferences -> + currentPreferences.toBuilder() + .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? { + 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 881b3a0f..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,82 +1,237 @@ 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.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.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.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 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) { - suspend fun sendReport(issue: String, report: String, eateryid: Int?): Any = - networkApi.sendReport( - report = ReportSendBody( - eatery = eateryid, - content = "$issue: $report" - ) - ) +class UserRepository @Inject constructor( + private val networkApi: NetworkApi, + val userPreferencesRepository: UserPreferencesRepository +) { + private val _loadedUser: MutableStateFlow = MutableStateFlow(null) + + /** + * The currently loaded user. Null if no user is logged in. + */ + 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 - suspend fun getUser(sessionId: String): GetApiResponse = - networkApi.fetchUser( - url = BuildConfig.GET_BACKEND_URL + "user", - body = GetApiRequestBody( - version = "1", - method = "retrieve", - params = GetApiUserParams( - sessionId = sessionId + // 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 = + tryRequest { + networkApi.sendReport( + report = ReportSendBody( + eatery = eateryID, + content = "$issue: $report" ) ) + } + + suspend fun addFavoriteItem(name: String) = tryRequest { + networkApi.addFavoriteItem( + accessToken = getAccessToken(), + item = FavoriteItem(item = name) ) + } - 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 - ) - ) + suspend fun removeFavoriteItem(name: String) = tryRequest { + networkApi.deleteFavoriteItem( + accessToken = getAccessToken(), + item = FavoriteItem(name) ) + } - suspend fun getTransactionHistory( - sessionId: String, - userId: String, - endDate: Date = Date(), - startDate: Date = Date.from( - endDate.toInstant().minus(Duration.ofDays(1460)) + suspend fun addFavoriteEatery(id: Int) = tryRequest { + networkApi.addFavoriteEatery( + accessToken = getAccessToken(), + eatery = FavoriteEatery(id), ) - ): 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 - ) + } + + suspend fun removeFavoriteEatery(id: Int) = tryRequest { + networkApi.deleteFavoriteEatery( + accessToken = getAccessToken(), + eatery = FavoriteEatery(id) + ) + } + + 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/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 54037bee..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 @@ -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,25 +46,32 @@ 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 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 -import com.cornellappdev.android.eatery.data.models.Account -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 com.cornellappdev.android.eatery.util.EateryPreview import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import kotlin.math.abs @OptIn( ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, @@ -71,12 +79,11 @@ import java.time.format.DateTimeFormatter ) @Composable fun AccountPage( - accountFilter: AccountType, - checkAccount: (AccountType) -> Account?, - checkMealPlan: () -> Account?, + 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 = @@ -91,21 +98,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 -> {} @@ -119,307 +126,425 @@ fun AccountPage( ), sheetElevation = 8.dp ) { - val innerListState = rememberLazyListState() - val isFirstVisible = - remember { derivedStateOf { innerListState.firstVisibleItemIndex > 1 } } + AccountPageContent( + onSettingsClicked, + accountTypeBalance, + accountFilter, + 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, + 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", - accountType = AccountType.MEALSWIPES, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan - ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) + .height(1.dp) + .background(GrayZero, CircleShape) + ) + accountTypeBalance.brbBalance?.let { AccountBalanceRow( accountName = "Big Red Bucks", - accountType = AccountType.BRBS, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan - ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) - AccountBalanceRow( - accountName = "City Bucks", - accountType = AccountType.CITYBUCKS, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan - ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(GrayZero, CircleShape) - ) - AccountBalanceRow( - accountName = "Laundry", - accountType = AccountType.LAUNDRY, - checkAccount = checkAccount, - checkMealPlan = checkMealPlan + 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, + showBottomSheet, + filterText, + setFilterText + ) + } + items( + getTransactionsOfType( + accountFilter, + filterText + ) + ) { + TransactionRow( + transaction = it, + isMealSwipes = accountFilter == TransactionAccountType.MEAL_SWIPES + ) + } + } + } +} + +@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, + showBottomSheet: suspend () -> Unit, + filterText: String, + setFilterText: ((String) -> Unit) +) { + val coroutineScope = rememberCoroutineScope() + 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() + } + }, + 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 + ) } } - items( - getTransactionsOfType( - 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") - val dateTime = LocalDateTime.parse(it.date, inputFormatter) - Row( + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = EateryBlue) + .then(Modifier.statusBarsPadding()) + .padding(bottom = 7.dp), + ) { + 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, - accountType: AccountType, - checkAccount: (AccountType) -> Account?, - checkMealPlan: () -> Account? + balance: Double, +) { + AccountRow(accountName, "$" + "%.2f".format(balance)) +} + +@Composable +fun AccountBalanceRow( + accountName: String, + swipes: Int +) { + AccountRow(accountName, "$swipes remaining") +} + +@Composable +private fun AccountRow( + accountName: String, + text: String ) { Row( modifier = Modifier.height(50.dp), @@ -433,15 +558,7 @@ fun AccountBalanceRow( 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" - }, + text = text, style = EateryBlueTypography.button, ) } @@ -449,11 +566,11 @@ fun AccountBalanceRow( @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( @@ -473,52 +590,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/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/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/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 65d8c9d5..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,9 +4,10 @@ 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 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.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel @@ -16,90 +17,88 @@ import com.cornellappdev.android.eatery.util.EateryPreview @OptIn(ExperimentalAnimationApi::class) @Composable fun ProfileScreen( - loginViewModel: LoginViewModel, + loginViewModel: LoginViewModel = hiltViewModel(), onSettingsClicked: () -> Unit, webViewEnabled: Boolean, onBackClick: () -> Unit ) { val state = loginViewModel.state.collectAsState().value ProfileScreenContent( - state, + isLoginState = state is LoginViewModel.State.Login, + 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, - checkAccount = loginViewModel::checkAccount, - checkMealPlan = loginViewModel::checkMealPlan, onSettingsClicked = onSettingsClicked, - getTransactionsOfType = loginViewModel::getTransactionsOfType, + accountFilter = if (state is LoginViewModel.State.Account) state.accountFilter else TransactionAccountType.BRBS, + + getTransactionsOfType = loginViewModel::getFilteredTransactions, updateAccountFilter = loginViewModel::updateAccountFilter ) } @Composable private fun ProfileScreenContent( - state: LoginViewModel.State, + isLoginState: Boolean, + accountTypeBalance: AccountBalances, loading: Boolean, onLoginPressed: () -> Unit, onSuccess: (String) -> Unit, webViewEnabled: Boolean, onBackClick: () -> Unit, onModalHidden: () -> Unit, - accountFilter: AccountType, - checkAccount: (AccountType) -> Account?, - checkMealPlan: () -> Account?, + accountFilter: TransactionAccountType, onSettingsClicked: () -> Unit, - getTransactionsOfType: (AccountType, String) -> List, - updateAccountFilter: (AccountType) -> Unit + getTransactionsOfType: (TransactionAccountType, String) -> List, + updateAccountFilter: (TransactionAccountType) -> 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 = AccountBalances( + brbBalance = 1234.56, + cityBucksBalance = 78.90, + laundryBalance = 12.34, + mealSwipes = 30 + ), loading = false, onLoginPressed = {}, onSuccess = {}, webViewEnabled = false, onBackClick = {}, onModalHidden = {}, - accountFilter = AccountType.BRBS, - checkAccount = { null }, - checkMealPlan = { null }, + accountFilter = TransactionAccountType.BRBS, onSettingsClicked = {}, getTransactionsOfType = { _, _ -> emptyList() }, updateAccountFilter = {}, 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..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 @@ -256,14 +257,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() 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..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,8 +25,7 @@ import javax.inject.Inject @HiltViewModel class CompareMenusBotViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, - private val eateryRepository: EateryRepository, + 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, 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/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 fed69b63..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 @@ -2,13 +2,12 @@ package com.cornellappdev.android.eatery.ui.viewmodels 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.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.repositories.UserPreferencesRepository +import com.cornellappdev.android.eatery.data.models.toTransactionAccountType 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 @@ -19,7 +18,6 @@ import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, private val userRepository: UserRepository, ) : ViewModel() { @@ -37,58 +35,43 @@ 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 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.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 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 - ) - - fun resetLogin() { - _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 + init { + viewModelScope.launch { + if (userRepository.isLoggedIn()) { + getFinancials() } } - 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) { + fun updateAccountFilter(newAccountType: TransactionAccountType) { val currState = _state.value if (currState !is State.Account) return @@ -103,15 +86,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.value?.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) @@ -130,63 +123,50 @@ class LoginViewModel @Inject constructor( fun onLogoutPressed() { val newState = State.Login() _state.value = newState - viewModelScope.launch { - CurrentUser.user = null - userPreferencesRepository.setIsLoggedIn(false) - userPreferencesRepository.saveLoginInfo("", "") - } - } - - init { - getSavedLoginInfo() - } - - 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() + } } - private fun getUser(sessionId: String) = viewModelScope.launch { + /** + * Fetches user data given [sessionId] and updates the state and user preferences. + */ + private suspend fun linkGETAccount(sessionId: String) { try { - val currState = _state.value - val user = userRepository.getUser(sessionId).response!! - val account = userRepository.getAccount(sessionId, user.id!!).response!!.accounts - val transactions = - userRepository.getTransactionHistory(sessionId, user.id).response!!.transactions - user.accounts = account - user.transactions = transactions - CurrentUser.user = user - - if (currState is State.Login) { - userPreferencesRepository.saveLoginInfo(sessionId, currState.password) - userPreferencesRepository.setIsLoggedIn(true) - } - val newState = State.Account( - user = user, - query = "", - accountFilter = AccountType.BRBS - ) - _state.value = newState + userRepository.linkGETAccount(sessionId) } catch (e: Exception) { + // 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/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 diff --git a/app/src/main/proto/user_prefs.proto b/app/src/main/proto/user_prefs.proto index b5b4cc6b..70c9737e 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -11,21 +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 = 11; + string accessToken = 12; + string refreshToken = 13; + int32 pin = 14; // repeated int32 recentSearches = 2; // string username = 3;