diff --git a/.gitignore b/.gitignore index d72e1288..feb16df9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,18 +38,7 @@ captures/ # IntelliJ *.iml -.idea/workspace.xml -.idea/tasks.xml -.idea/gradle.xml -.idea/assetWizardSettings.xml -.idea/dictionaries -.idea/libraries -.idea/* -# Android Studio 3 in .gitignore file. -.idea/caches -.idea/modules.xml -# Comment next line if keeping position of elements in Navigation Editor is relevant for you -.idea/navEditor.xml +**/.idea/ # Keystore files # Uncomment the following lines if you do not want to check your keystore files in. diff --git a/app/build.gradle b/app/build.gradle index 885363af..fdc87cd4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -58,8 +58,8 @@ android { "GOOGLE_AUTH_CLIENT_ID", secretsProperties["GOOGLE_AUTH_CLIENT_ID"] ) signingConfig signingConfigs.debug - buildConfigField("boolean", "ONBOARDING_FLAG", "false") - buildConfigField("boolean", "CHECK_IN_FLAG", "false") + buildConfigField("boolean", "ONBOARDING_FLAG", "true") + buildConfigField("boolean", "CHECK_IN_FLAG", "true") } } compileOptions { diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt index c0f212a1..48c658c4 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt @@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import com.cornellappdev.uplift.data.models.ApiResponse import com.cornellappdev.uplift.data.models.gymdetail.UpliftGym import com.cornellappdev.uplift.util.getDistanceBetween +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.text.SimpleDateFormat import java.time.LocalDate import java.time.ZoneId @@ -135,15 +137,19 @@ class CheckInRepository @Inject constructor( /** * Records that the user has completed a check-in today by storing the current date in the * DataStore. Used to prevent additional prompts for the remainder of the day after a check in. + * + * Suspends until the write completes. Returns true if the date was persisted, false otherwise. */ - fun markCheckInToday() { - CoroutineScope(Dispatchers.IO).launch { - try { - val today = LocalDate.now(zone).toString() - dataStore.edit { it[KEY_CHECKIN_LAST_DATE] = today } - } catch (e: Exception){ - Log.e("CheckInRepository", "Failed to write check-in date", e) - } + suspend fun markCheckInToday(): Boolean = withContext(Dispatchers.IO) { + try { + val today = LocalDate.now(zone).toString() + dataStore.edit { it[KEY_CHECKIN_LAST_DATE] = today } + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e("CheckInRepository", "Failed to write check-in date", e) + false } } diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/WorkoutLogRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/WorkoutLogRepository.kt new file mode 100644 index 00000000..f89e5639 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/WorkoutLogRepository.kt @@ -0,0 +1,22 @@ +package com.cornellappdev.uplift.data.repositories + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Broadcasts a signal whenever a workout is successfully logged, so that other currently-active + * screens (e.g. history/streaks) can refresh their data without polling or being tightly coupled + * to whatever triggered the log (check-in, manual entry, etc). + */ +@Singleton +class WorkoutLogRepository @Inject constructor() { + private val _workoutLoggedEvent = MutableSharedFlow(extraBufferCapacity = 1) + val workoutLoggedEvent: SharedFlow = _workoutLoggedEvent.asSharedFlow() + + fun notifyWorkoutLogged() { + _workoutLoggedEvent.tryEmit(Unit) + } +} diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/components/general/CheckInPopUp.kt b/app/src/main/java/com/cornellappdev/uplift/ui/components/general/CheckInPopUp.kt index da904288..457a2f2c 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/components/general/CheckInPopUp.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/components/general/CheckInPopUp.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.cornellappdev.uplift.ui.components.profile.checkin.CheckInComplete +import com.cornellappdev.uplift.ui.components.profile.checkin.CheckInFailed import com.cornellappdev.uplift.ui.components.profile.checkin.CheckInPrompt import com.cornellappdev.uplift.ui.theme.AppColors import com.cornellappdev.uplift.ui.viewmodels.profile.CheckInMode @@ -66,6 +67,10 @@ fun CheckInPopUp( CheckInMode.Complete -> CheckInComplete( onClosePopUp = onClosePopUp ) + CheckInMode.Failed -> CheckInFailed( + onRetry = onCheckIn, + onClosePopUp = onClosePopUp + ) } } diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/checkin/CheckInFailed.kt b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/checkin/CheckInFailed.kt new file mode 100644 index 00000000..9d5b47c3 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/checkin/CheckInFailed.kt @@ -0,0 +1,79 @@ +package com.cornellappdev.uplift.ui.components.profile.checkin + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.uplift.R +import com.cornellappdev.uplift.ui.theme.AppColors +import com.cornellappdev.uplift.ui.theme.AppTextStyles + +@Composable +fun CheckInFailed( + onRetry: () -> Unit, + onClosePopUp: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Couldn't log your workout.", + style = AppTextStyles.BodySemibold, + color = AppColors.Black + ) + Row( + modifier = Modifier.height(34.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + modifier = Modifier + .width(93.dp) + .height(34.dp), + shape = RoundedCornerShape(size = 11.05263.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = AppColors.LightYellow, + contentColor = AppColors.Black + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + onClick = onRetry + ) { + Text( + text = "Retry", + style = AppTextStyles.LabelBig, + color = AppColors.Black + ) + } + + Image( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = "close pop up", + contentScale = ContentScale.None, + modifier = Modifier.clickable { onClosePopUp() } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CheckInFailedPreview() { + CheckInFailed({}, {}) +} diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/CheckInViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/CheckInViewModel.kt index 0a17dac8..3381e832 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/CheckInViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/CheckInViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.cornellappdev.uplift.data.repositories.CheckInRepository import com.cornellappdev.uplift.data.repositories.ConfettiRepository import com.cornellappdev.uplift.data.repositories.LocationRepository +import com.cornellappdev.uplift.data.repositories.WorkoutLogRepository import com.cornellappdev.uplift.ui.viewmodels.UpliftViewModel import com.cornellappdev.uplift.util.isOpen import com.cornellappdev.uplift.util.todayIndex @@ -20,8 +21,9 @@ private const val tag = "CheckInVM" * UI mode for the Check-In pop up. * - [Prompt]: user is near an open gym and can choose to check in. * -[Complete]: a check in was just logged and the confirmation/congratulation state is shown. + * -[Failed]: the log workout mutation failed and the user can retry or dismiss. */ -enum class CheckInMode {Prompt, Complete} +enum class CheckInMode {Prompt, Complete, Failed} /** * UI state backing the Check-In pop-up @@ -45,7 +47,8 @@ data class CheckInUiState( @HiltViewModel class CheckInViewModel @Inject constructor( private val checkInRepository: CheckInRepository, - private val confettiRepository: ConfettiRepository + private val confettiRepository: ConfettiRepository, + private val workoutLogRepository: WorkoutLogRepository ) : UpliftViewModel(CheckInUiState()) { private var locationJob: Job? = null @@ -67,7 +70,7 @@ class CheckInViewModel @Inject constructor( showPopUp = true, mode = if (inComplete) CheckInMode.Complete else CheckInMode.Prompt, gymName = gym.name, - gymId = gym.id, + gymId = gym.facilityId, timeText = checkInRepository.formatTime(System.currentTimeMillis()) ) } @@ -126,12 +129,14 @@ class CheckInViewModel @Inject constructor( } /** - * Marks the user as checked in for the day, triggering a cooldown til the end of day and a - * logworkout mutation through [checkInRepository]. On a successful call, transitions UI into - * [CheckInMode.Complete] and bursts confetti from popup through a [confettiRepository]. + * Logs a workout via [checkInRepository]. Only on a successful mutation marks the + * user as checked in for the day (triggering the end-of-day cooldown, awaited and retried once + * if the write fails; the workout mutation itself is never retried), transition the UI into + * [CheckInMode.Complete], and burst confetti through [confettiRepository] and notify + * [workoutLogRepository] so screens showing history/streaks can refresh. * - * Note: Temporarily skips over failed backend log workout call to keep functionality while auth and - * sign in are not working. + * If the mutation fails, the UI transitions into [CheckInMode.Failed] instead, so the user + * knows the workout wasn't recorded and can retry rather than seeing a false success state. */ fun onCheckIn() = viewModelScope.launch { val currentGymId = uiStateFlow.value.gymId @@ -142,22 +147,37 @@ class CheckInViewModel @Inject constructor( return@launch } try { - checkInRepository.markCheckInToday() - applyMutation { - copy( - showPopUp = true, - mode = CheckInMode.Complete - ) - } - confettiRepository.showConfetti(ConfettiViewModel.ConfettiUiState()) val logged = checkInRepository.logWorkoutFromCheckIn(gymIdInt) if (logged) { Log.d(tag, "Workout successfully logged to backend") + if (!checkInRepository.markCheckInToday() && !checkInRepository.markCheckInToday()) { + Log.e(tag, "Workout logged but check-in cooldown could not be persisted") + } + applyMutation { + copy( + showPopUp = true, + mode = CheckInMode.Complete + ) + } + confettiRepository.showConfetti(ConfettiViewModel.ConfettiUiState()) + workoutLogRepository.notifyWorkoutLogged() } else { Log.e(tag, "Workout failed to log to backend") + applyMutation { + copy( + showPopUp = true, + mode = CheckInMode.Failed + ) + } } } catch (e: Exception) { Log.e(tag, "Error checking in", e) + applyMutation { + copy( + showPopUp = true, + mode = CheckInMode.Failed + ) + } } } diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt index 76b15adb..a96a447b 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt @@ -4,6 +4,7 @@ import android.net.Uri import android.util.Log import androidx.lifecycle.viewModelScope import com.cornellappdev.uplift.data.repositories.ProfileRepository +import com.cornellappdev.uplift.data.repositories.WorkoutLogRepository import com.cornellappdev.uplift.ui.UpliftRootRoute import com.cornellappdev.uplift.ui.components.profile.workouts.HistoryItem import com.cornellappdev.uplift.ui.nav.RootNavigationRepository @@ -12,6 +13,7 @@ import com.cornellappdev.uplift.util.timeAgoString import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collectLatest import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate @@ -52,12 +54,18 @@ data class ProfileUiState( class ProfileViewModel @Inject constructor( private val profileRepository: ProfileRepository, private val rootNavigationRepository: RootNavigationRepository, + private val workoutLogRepository: WorkoutLogRepository, ) : UpliftViewModel(ProfileUiState()) { private var loadingJob: Job? = null init { reload() + viewModelScope.launch { + workoutLogRepository.workoutLoggedEvent.collectLatest { + reload() + } + } } fun reload() {