1+ package com.cornellappdev.uplift.data.repositories
2+
3+ import android.location.Location
4+ import android.util.Log
5+ import androidx.datastore.core.DataStore
6+ import androidx.datastore.preferences.core.edit
7+ import androidx.datastore.preferences.core.longPreferencesKey
8+ import androidx.datastore.preferences.core.stringPreferencesKey
9+ import com.cornellappdev.uplift.data.models.ApiResponse
10+ import com.cornellappdev.uplift.data.models.gymdetail.UpliftGym
11+ import com.cornellappdev.uplift.util.getDistanceBetween
12+ import kotlinx.coroutines.CoroutineScope
13+ import kotlinx.coroutines.Dispatchers
14+ import kotlinx.coroutines.flow.MutableStateFlow
15+ import kotlinx.coroutines.flow.SharingStarted
16+ import kotlinx.coroutines.flow.StateFlow
17+ import kotlinx.coroutines.flow.asStateFlow
18+ import kotlinx.coroutines.flow.map
19+ import kotlinx.coroutines.flow.stateIn
20+ import kotlinx.coroutines.flow.update
21+ import kotlinx.coroutines.launch
22+ import java.text.SimpleDateFormat
23+ import java.time.LocalDate
24+ import java.time.ZoneId
25+ import java.util.Date
26+ import java.util.Locale
27+ import java.util.TimeZone
28+ import androidx.datastore.preferences.core.Preferences
29+ import com.apollographql.apollo.ApolloClient
30+ import com.cornellappdev.uplift.LogWorkoutMutation
31+ import java.time.Instant
32+ import javax.inject.Inject
33+ import javax.inject.Singleton
34+
35+ @Singleton
36+ class CheckInRepository @Inject constructor(
37+ val upliftApiRepository : UpliftApiRepository ,
38+ private val dataStore : DataStore <Preferences >,
39+ private val apolloClient : ApolloClient ,
40+ private val userInfoRepository : UserInfoRepository
41+ ){
42+ private val _nearestGymFlow = MutableStateFlow <UpliftGym ?>(null )
43+ val nearestGymFlow: StateFlow <UpliftGym ?> = _nearestGymFlow .asStateFlow()
44+
45+ private val proximityMiles = 0.05f
46+
47+ /* *
48+ * Evaluates the user's proximity to all gyms and updates [_nearestGymFlow] with the closest gym
49+ * if it is within [proximityMiles]. Otherwise sets it to null.
50+ */
51+ fun evaluateProximity (location : Location ) {
52+ val gymResponse = upliftApiRepository.gymApiFlow.value
53+ if (gymResponse is ApiResponse .Success ) {
54+ val gyms = gymResponse.data
55+ val nearestGym = gyms.minByOrNull { gym ->
56+ getDistanceBetween(
57+ location.latitude,
58+ location.longitude,
59+ gym.latitude,
60+ gym.longitude
61+ )
62+ }
63+ nearestGym?.let { gym ->
64+ val distance = getDistanceBetween(
65+ location.latitude,
66+ location.longitude,
67+ gym.latitude,
68+ gym.longitude
69+ )
70+ if (distance <= proximityMiles) {
71+ _nearestGymFlow .update { gym }
72+ } else {
73+ _nearestGymFlow .update { null }
74+ }
75+ }
76+ } else {
77+ _nearestGymFlow .update { null }
78+ }
79+ }
80+
81+ /* *
82+ * Formats a given timestamps [ms] into a human-readable string for display on the check in feature
83+ */
84+ fun formatTime (ms : Long ): String =
85+ SimpleDateFormat (" h:mm a" , Locale .US ).apply {
86+ timeZone = TimeZone .getTimeZone(" America/New_York" )
87+ }.format(Date (ms))
88+
89+
90+ private val KEY_CHECKIN_SUPPRESS_UNTIL = longPreferencesKey(" checkin_suppress_until" )
91+ private val KEY_CHECKIN_LAST_DATE = stringPreferencesKey(" checkin_last_date" )
92+ private val zone: ZoneId = ZoneId .systemDefault()
93+
94+ /* *
95+ * A [StateFlow] indicating whether the check-in prompt should be shown. Based on dismiss
96+ * cooldown and whether the user has already checked in today.
97+ *
98+ * Note: Since DataStore only emits when data changes, this flow isn't automatically re-evaluated
99+ * after the cooldown ends if the user keeps the app open for more than 2 hours. However, the
100+ * cooldown will reset correctly once the app restarts.
101+ */
102+ val checkInPromptAllowed: StateFlow <Boolean > = dataStore.data
103+ .map { prefs ->
104+ val currentTime = System .currentTimeMillis()
105+ val currentDate = LocalDate .now(zone).toString()
106+ val suppressUntil = prefs[KEY_CHECKIN_SUPPRESS_UNTIL ] ? : 0L
107+ val lastCheckInDate = prefs[KEY_CHECKIN_LAST_DATE ]
108+ val cooldownOver = currentTime >= suppressUntil
109+ val notCheckedInToday = lastCheckInDate != currentDate
110+ cooldownOver && notCheckedInToday
111+ }
112+ .stateIn(
113+ CoroutineScope (Dispatchers .Main ),
114+ SharingStarted .Eagerly ,
115+ true
116+ )
117+
118+
119+ /* *
120+ * Sets a temporary 2 hour suppression window during which the check-in prompt will not appear.
121+ * Persists the suppression time using the DataStore.
122+ */
123+ fun markCheckInDismissedFor () {
124+ CoroutineScope (Dispatchers .IO ).launch{
125+ try {
126+ val until = System .currentTimeMillis() + 2 * 60 * 60 * 1000
127+ dataStore.edit { it[KEY_CHECKIN_SUPPRESS_UNTIL ] = until }
128+ } catch (e: Exception ) {
129+ Log .e(" CheckInRepository" , " Failed to mark check-in dismissed" , e)
130+ }
131+ }
132+ }
133+
134+ /* *
135+ * Records that the user has completed a check-in today by storing the current date in the
136+ * DataStore. Used to prevent additional prompts for the remainder of the day after a check in.
137+ */
138+ fun markCheckInToday () {
139+ CoroutineScope (Dispatchers .IO ).launch {
140+ try {
141+ val today = LocalDate .now(zone).toString()
142+ dataStore.edit { it[KEY_CHECKIN_LAST_DATE ] = today }
143+ } catch (e: Exception ){
144+ Log .e(" CheckInRepository" , " Failed to write check-in date" , e)
145+ }
146+ }
147+ }
148+
149+ /* *
150+ * Logs a completed workout to the backend. Returns true if the mutation succeeded, false otherwise.
151+ */
152+ suspend fun logWorkoutFromCheckIn (gymId : Int ): Boolean {
153+ val userId = userInfoRepository.getUserIdFromDataStore()?.toIntOrNull() ? : return false
154+ val time = Instant .now().toString()
155+
156+ return try {
157+ val response = apolloClient
158+ .mutation(LogWorkoutMutation (facilityId = gymId, workoutTime = time, id = userId ))
159+ .execute()
160+
161+ val ok = response.data?.logWorkout?.workoutFields != null && ! response.hasErrors()
162+ if (! ok) {
163+ Log .e(" CheckInRepository" , " LogWorkout errors=${response.errors} " )
164+ }
165+ ok
166+ } catch (t: Throwable ){
167+ Log .e(" CheckInRepository" , " LogWorkout exception" , t)
168+ false
169+ }
170+ }
171+ }
0 commit comments