diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt index 09699820..398cdea5 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/MoshiAdapters.kt @@ -1,3 +1,5 @@ +@file:Suppress("unused") + package com.cornellappdev.android.eatery.data import com.cornellappdev.android.eatery.data.models.AccountType @@ -116,7 +118,7 @@ class AccountTypeAdapter { "brb" } - AccountType.CITYBUCKS -> { + AccountType.CITY_BUCKS -> { "city bucks" } @@ -124,10 +126,6 @@ class AccountTypeAdapter { "laundry" } - AccountType.MEALSWIPES -> { - "meal plan" - } - else -> { "other" } @@ -155,7 +153,7 @@ class AccountTypeAdapter { return if (accountName.contains("brb", ignoreCase = true)) { AccountType.BRBS } else if (accountName.contains("city bucks", ignoreCase = true)) { - AccountType.CITYBUCKS + AccountType.CITY_BUCKS } else if (accountName.contains("laundry", ignoreCase = true)) { AccountType.LAUNDRY } else { diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt index 6d597e7b..3db22dbe 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/NetworkingApi.kt @@ -1,42 +1,20 @@ 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.Accounts +import com.cornellappdev.android.eatery.data.models.AuthorizedUser import com.cornellappdev.android.eatery.data.models.Eatery -import com.cornellappdev.android.eatery.data.models.Event -import com.cornellappdev.android.eatery.data.models.GetApiAccountsParams -import com.cornellappdev.android.eatery.data.models.GetApiRequestBody import com.cornellappdev.android.eatery.data.models.GetApiResponse -import com.cornellappdev.android.eatery.data.models.GetApiTransactionHistoryParams -import com.cornellappdev.android.eatery.data.models.GetApiUserParams +import com.cornellappdev.android.eatery.data.models.LoginRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody -import com.cornellappdev.android.eatery.data.models.TransactionsResponse +import com.cornellappdev.android.eatery.data.models.Transactions import com.cornellappdev.android.eatery.data.models.User import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path -import retrofit2.http.Url interface NetworkApi { - @POST() - suspend fun fetchUser( - @Url url: String, - @Body body: GetApiRequestBody - ): GetApiResponse - - @POST() - suspend fun fetchAccounts( - @Url url: String, - @Body body: GetApiRequestBody - ): GetApiResponse - - @POST() - suspend fun fetchTransactionHistory( - @Url url: String, - @Body body: GetApiRequestBody - ): GetApiResponse - @GET("/eatery/") suspend fun fetchEateries(): List @@ -46,12 +24,31 @@ interface NetworkApi { @GET("/eatery/simple") suspend fun fetchHomeEateries(): List - @GET("/event") - suspend fun fetchEvents(): ApiResponse> - - @POST("/report/") suspend fun sendReport( @Body report: ReportSendBody ): GetApiResponse + + @POST("/user/authorize/") + suspend fun authorizeUser( + @Header("Authorization") sessionId: String, + @Body loginRequest: LoginRequest + ): AuthorizedUser + + @POST("/user/accounts/") + suspend fun getUserAccounts( + @Header("Authorization") sessionId: String, + @Body user: AuthorizedUser + ): Accounts + + @POST("/user/transactions/") + suspend fun getUserTransactions( + @Header("Authorization") sessionId: String, + @Body user: AuthorizedUser + ): Transactions + + @GET("/user/{id}/") + suspend fun getUserData( + @Path("id") id: Long + ): User } \ 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..dbdff9ee 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/Eatery.kt @@ -19,7 +19,6 @@ import java.time.Duration import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit import java.util.Date @JsonClass(generateAdapter = true) @@ -40,6 +39,7 @@ data class Eatery( @Json(name = "wait_times") val waitTimes: List? = null, @Json(name = "alerts") val alerts: List? = null, ) { + // todo - investigate unused methods fun getWalkTimes(): Int? { val currentLocation = LocationHandler.currentLocation.value val results = floatArrayOf(0f) @@ -56,29 +56,6 @@ data class Eatery( return ((results[0] / AVERAGE_WALK_SPEED) / 60).toInt() } - fun getWaitTimes(): String? { - if (waitTimes.isNullOrEmpty()) - return null - - val waitTimeDay = waitTimes.find { waitTimeDay -> - // checks if today is the right day - waitTimeDay.canonicalDate - ?.toInstant() - ?.truncatedTo(ChronoUnit.DAYS) - ?.equals(Date().toInstant().truncatedTo(ChronoUnit.DAYS)) ?: true - }?.data - - val waitTimes: WaitTimeData? = waitTimeDay?.find { waitTimeData -> - waitTimeData.timestamp?.isBefore(LocalDateTime.now()) == true - } - - return if (waitTimes != null) { - "${waitTimes.waitTimeLow?.div(60)}-${waitTimes.waitTimeHigh?.div(60)}" - } else { - null - } - } - private fun getTodaysEvents(): List { val currentTime = LocalDateTime.now() @@ -111,18 +88,6 @@ data class Eatery( return todayEvents } - /** - * Returns the currently active event, or null if no event is active. - * - * Example: At 1 PM, Morrison will return the lunch event. - */ - fun getCurrentEvent(): Event? { - return getTodaysEvents().find { - it.startTime?.isBefore(LocalDateTime.now()) ?: true - && it.endTime?.isAfter(LocalDateTime.now()) ?: true - } - } - /** * Returns the event that should be displayed at the Ithaca local time * If there is currently a meal going on, that is displayed @@ -168,7 +133,7 @@ data class Eatery( * for louies, it returns [("General",some string duration)] * Note, string duration are in the format "11:00 AM - 2:30 PM" */ - fun getTypeMeal(currSelectedDay: DayOfWeek): List>? { + fun getTypeMeal(currSelectedDay: DayOfWeek): List> { val timeFormatter = DateTimeFormatter.ofPattern("h:mm a") val uniqueMeals = LinkedHashMap() @@ -207,7 +172,6 @@ data class Eatery( fun getSelectedDayMeal(meal: MealFilter, day: Int): List? { var currentDay = LocalDate.now() currentDay = currentDay.plusDays(day.toLong()) -// Log.d(name, events?.filter { currentDay.dayOfYear == it.startTime?.dayOfYear }.toString()) return events?.filter { event -> currentDay.dayOfYear == event.startTime?.dayOfYear && meal.text.contains(event.description) } @@ -239,16 +203,6 @@ data class Eatery( return getOpenUntil() == null } - fun isClosingInTen(): Boolean { - val currentTime = LocalDateTime.now() - val currentEvents = getCurrentEvents() - if (currentEvents.isEmpty()) - return false - - val endTime = currentEvents.first().endTime ?: return false - return currentTime.plusMinutes(10).isAfter(endTime) - } - /** * Returns true if the eatery has a current event and that event is ending within [minutes]. */ @@ -297,14 +251,12 @@ data class Eatery( * e.g. For Oken, {Monday -> ["11:00 AM - 2:30 PM", "4:30 PM - 9:00 PM"], Sunday -> "Closed"} */ private fun operatingHours(): Map> { - var dailyHours = mutableMapOf>() + val dailyHours = mutableMapOf>() events?.forEach { event -> val dayOfWeek = event.startTime?.dayOfWeek val openTime = event.startTime?.format(DateTimeFormatter.ofPattern("h:mm a")) val closeTime = event.endTime?.format(DateTimeFormatter.ofPattern("h:mm a")) -// Log.d("event", event.toString()) - val timeString = "$openTime - $closeTime" if (dayOfWeek != null && dailyHours[dayOfWeek]?.none { it.contains(timeString) } != false) { @@ -312,7 +264,7 @@ data class Eatery( } } - DayOfWeek.values().forEach { dayOfWeek -> + DayOfWeek.entries.forEach { dayOfWeek -> dailyHours.computeIfAbsent(dayOfWeek) { mutableListOf("Closed") } } @@ -329,7 +281,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 +342,7 @@ data class Eatery( } } - var formattedHoursList = formattedHours.toList().sortedBy { entry -> + val formattedHoursList = formattedHours.toList().sortedBy { entry -> val firstDay = entry.first.split(" to ", " ", limit = 2).first() dayOrder[firstDay] ?: Int.MAX_VALUE } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt index 697dab21..7f0d8a54 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/models/User.kt @@ -1,3 +1,5 @@ +@file:Suppress("AddExplicitTargetToParameterAnnotation") + package com.cornellappdev.android.eatery.data.models import com.squareup.moshi.Json @@ -5,70 +7,100 @@ import com.squareup.moshi.JsonClass @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 = 0.0, + @Json(name = "city_bucks_balance") val cityBucksBalance: Double = 0.0, + @Json(name = "laundry_balance") val laundryBalance: Double = 0.0, + @Json(name = "transactions") val transactions: List? = listOf(), + @Json(name = "meal_swipes") val mealSwipes: Int? = null // todo - backend should make this +) + +@JsonClass(generateAdapter = true) +data class LoginRequest( + @Json(name = "device_id") val deviceId: String = "", + @Json(name = "fcm_token") val fcmToken: String = "", + @Json(name = "pin") val pin: Int = 0 +) + +@JsonClass(generateAdapter = true) +data class AuthorizedUser( + @Json(name = "id") val id: Long = 0, + @Json(name = "device_id") val deviceId: String = "", + @Json(name = "fcm_token") val fcmToken: String = "", + @Json(name = "pin") val pin: Int = 0 ) @JsonClass(generateAdapter = true) -data class AccountsResponse( - @Json(name = "accounts") val accounts: List? = null +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..abcb661d 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/EateryRepository.kt @@ -1,9 +1,7 @@ package com.cornellappdev.android.eatery.data.repositories import com.cornellappdev.android.eatery.data.NetworkApi -import com.cornellappdev.android.eatery.data.models.ApiResponse import com.cornellappdev.android.eatery.data.models.Eatery -import com.cornellappdev.android.eatery.data.models.Event import com.cornellappdev.android.eatery.ui.viewmodels.state.EateryApiResponse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -28,9 +26,6 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { private suspend fun getHomeEateries(): List = networkApi.fetchHomeEateries() - private suspend fun getAllEvents(): ApiResponse> = - networkApi.fetchEvents() - private val _eateryFlow: MutableStateFlow>> = MutableStateFlow(EateryApiResponse.Pending) @@ -76,7 +71,7 @@ class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { try { val eateries = getAllEateries() _eateryFlow.value = EateryApiResponse.Success(eateries) - eateryApiCache.update { map -> + eateryApiCache.update { eateries.filter { it.id != null } .associate { it.id!! to EateryApiResponse.Success(it) } .withDefault { EateryApiResponse.Error } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt index 87ecbb7d..b3d1d9fa 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserPreferencesRepository.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -127,4 +128,17 @@ class UserPreferencesRepository @Inject constructor( suspend fun fetchLoginInfo(): Pair = Pair(userPreferencesFlow.first().username, userPreferencesFlow.first().password) + + suspend fun setDeviceId(deviceId: java.util.UUID) { + userPreferencesStore.updateData { currentPreferences -> + currentPreferences.toBuilder() + .setDeviceId(deviceId.toString()) + .build() + } + } + + suspend fun getDeviceId(): String? { + val id: String? = userPreferencesFlow.firstOrNull()?.deviceId + return if (id.isNullOrEmpty()) null else id + } } diff --git a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt index 881b3a0f..930f0983 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/data/repositories/UserRepository.kt @@ -1,82 +1,63 @@ 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.LoginRequest import com.cornellappdev.android.eatery.data.models.ReportSendBody -import com.cornellappdev.android.eatery.data.models.TransactionsResponse import com.cornellappdev.android.eatery.data.models.User -import java.text.SimpleDateFormat -import java.time.Duration -import java.util.Date -import java.util.Locale +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton @Singleton class UserRepository @Inject constructor(private val networkApi: NetworkApi) { - suspend fun sendReport(issue: String, report: String, eateryid: Int?): Any = + private val _loadedUser: MutableStateFlow = MutableStateFlow(null) + + /** + * The currently loaded user. Null if no user is logged in. + */ + val loadedUser: StateFlow = _loadedUser.asStateFlow() + + + suspend fun sendReport(issue: String, report: String, eateryID: Int?): Any = networkApi.sendReport( report = ReportSendBody( - eatery = eateryid, + eatery = eateryID, content = "$issue: $report" ) ) - suspend fun getUser(sessionId: String): GetApiResponse = - networkApi.fetchUser( - url = BuildConfig.GET_BACKEND_URL + "user", - body = GetApiRequestBody( - version = "1", - method = "retrieve", - params = GetApiUserParams( - sessionId = sessionId - ) - ) - ) - - suspend fun 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 getTransactionHistory( + /** + * Fetches the user from backend. + */ + suspend fun getUser( sessionId: String, - userId: String, - endDate: Date = Date(), - startDate: Date = Date.from( - endDate.toInstant().minus(Duration.ofDays(1460)) + deviceId: String, + fcmToken: String + ): User { + val bearerToken = "Bearer $sessionId" + val authorizedUser = networkApi.authorizeUser( + sessionId = bearerToken, + loginRequest = LoginRequest(deviceId = deviceId, pin = 1234, fcmToken = fcmToken) ) - ): 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 - ) - ) + // load accounts in case needed + networkApi.getUserAccounts( + sessionId = bearerToken, + user = authorizedUser ) - ) -} + val transactions = networkApi.getUserTransactions( + sessionId = bearerToken, + user = authorizedUser + ).transactions + val userWithData = networkApi.getUserData( + id = authorizedUser.id + ).copy(transactions = transactions) + _loadedUser.value = userWithData + return userWithData + } + + fun logout() { + _loadedUser.value = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/components/login/AccountPage.kt index 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/screens/AccountScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/AccountScreen.kt deleted file mode 100644 index ef58078f..00000000 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/AccountScreen.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.cornellappdev.android.eatery.ui.screens - -import androidx.compose.runtime.Composable -import com.cornellappdev.android.eatery.data.models.User - -@Composable -fun AccountScreen() { - -} - -object CurrentUser { - var user: User? = null -} diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt index 65d8c9d5..944e9cbb 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/ProfileScreen.kt @@ -4,9 +4,9 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.tooling.preview.Preview -import com.cornellappdev.android.eatery.data.models.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.ui.components.login.AccountPage import com.cornellappdev.android.eatery.ui.components.login.LoginPage import com.cornellappdev.android.eatery.ui.viewmodels.LoginViewModel @@ -23,83 +23,81 @@ fun ProfileScreen( ) { 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..865c3ea4 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/screens/SettingsScreen.kt @@ -256,14 +256,9 @@ fun SettingsScreen( modifier = Modifier .fillMaxWidth() .padding(bottom = 34.dp), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "Logged in as ${state.user.userName!!.substringBefore('@')}", - style = EateryBlueTypography.h5, - color = GrayFive - ) Button( onClick = { loginViewModel.onLogoutPressed() diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/theme/Color.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/theme/Color.kt index 2f5e7dc2..a6d1ab8f 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/theme/Color.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/theme/Color.kt @@ -16,6 +16,7 @@ val Red = Color(0xFFF2655D) val Green = Color(0xFF63C774) val Yellow = Color(0xFFFEC50E) val Orange = Color(0xFFFF990E) +val Black = Color(0xFF050505) /** * Interpolates a color between [color1] and [color2] by choosing a color a [fraction] in between. diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt index 162833f8..8faa8245 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/CompareMenusBotViewModel.kt @@ -26,8 +26,8 @@ import javax.inject.Inject @HiltViewModel class CompareMenusBotViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, - private val eateryRepository: EateryRepository, + userPreferencesRepository: UserPreferencesRepository, + eateryRepository: EateryRepository, private val userRepository: UserRepository, ) : ViewModel() { @@ -69,7 +69,7 @@ class CompareMenusBotViewModel @Inject constructor( userPreferencesRepository.favoritesFlow, filtersFlow, selectedEateriesFlow - ) { eateriesApiResponse, favorites, filters, selected -> + ) { eateriesApiResponse, _, filters, selected -> when (eateriesApiResponse) { is EateryApiResponse.Success -> { _compareMenusUiState.update { currentState -> diff --git a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt index fed69b63..3fc72855 100644 --- a/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/android/eatery/ui/viewmodels/LoginViewModel.kt @@ -1,20 +1,23 @@ package com.cornellappdev.android.eatery.ui.viewmodels +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.cornellappdev.android.eatery.data.models.Account -import com.cornellappdev.android.eatery.data.models.AccountType +import com.cornellappdev.android.eatery.data.models.AccountBalances import com.cornellappdev.android.eatery.data.models.Transaction +import com.cornellappdev.android.eatery.data.models.TransactionAccountType import com.cornellappdev.android.eatery.data.models.User +import com.cornellappdev.android.eatery.data.models.toTransactionAccountType import com.cornellappdev.android.eatery.data.repositories.UserPreferencesRepository import com.cornellappdev.android.eatery.data.repositories.UserRepository -import com.cornellappdev.android.eatery.ui.screens.CurrentUser import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.util.UUID import javax.inject.Inject @HiltViewModel @@ -37,58 +40,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 - ) + init { + getSavedLoginInfo() + } 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 - } - } - 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 +91,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) @@ -131,16 +129,12 @@ class LoginViewModel @Inject constructor( val newState = State.Login() _state.value = newState viewModelScope.launch { - CurrentUser.user = null + userRepository.logout() userPreferencesRepository.setIsLoggedIn(false) userPreferencesRepository.saveLoginInfo("", "") } } - init { - getSavedLoginInfo() - } - private fun getSavedLoginInfo() = viewModelScope.launch { if (userPreferencesRepository.getIsLoggedIn()) { val loginInfo = userPreferencesRepository.fetchLoginInfo() @@ -152,17 +146,20 @@ class LoginViewModel @Inject constructor( getUser(sessionId) } + /** + * Fetches user data given [sessionId] and updates the state and user preferences. + */ private fun getUser(sessionId: String) = viewModelScope.launch { + val currState = _state.value + if (userPreferencesRepository.getDeviceId() == null) { + userPreferencesRepository.setDeviceId(UUID.randomUUID()) + } 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 - + val fcmToken = + com.google.firebase.messaging.FirebaseMessaging.getInstance().token.await() + val deviceId = userPreferencesRepository.getDeviceId()!! + Log.d("debug", "sessionId: $sessionId, deviceId: $deviceId, fcmToken: $fcmToken") + val user = userRepository.getUser(sessionId, deviceId, fcmToken) if (currState is State.Login) { userPreferencesRepository.saveLoginInfo(sessionId, currState.password) userPreferencesRepository.setIsLoggedIn(true) @@ -170,10 +167,11 @@ class LoginViewModel @Inject constructor( val newState = State.Account( user = user, query = "", - accountFilter = AccountType.BRBS + accountFilter = TransactionAccountType.BRBS ) _state.value = newState } catch (e: Exception) { + // todo - error state val currState = _state.value if (currState is State.Login) { val newState = State.Login( 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..4ac3736d 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -27,6 +27,8 @@ message UserPreferences { map itemFavorites = 11; + string deviceId = 12; + // repeated int32 recentSearches = 2; // string username = 3; // // Must be encrypted / decrypted.