Skip to content

Commit 4382061

Browse files
Merge pull request #99 from cuappdev/melissa/check-in-popup
Add Check-In Pop-Up Feature for Nearby Gyms
2 parents aa14d09 + 67c3409 commit 4382061

11 files changed

Lines changed: 793 additions & 90 deletions

File tree

app/src/main/java/com/cornellappdev/uplift/MainActivity.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package com.cornellappdev.uplift
22

3+
import android.content.Context
34
import android.content.pm.ActivityInfo
45
import android.os.Bundle
56
import androidx.activity.ComponentActivity
67
import androidx.activity.compose.setContent
8+
import androidx.activity.viewModels
79
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
810
import com.cornellappdev.uplift.data.repositories.DatastoreRepository
911
import com.cornellappdev.uplift.ui.MainNavigationWrapper
1012
import com.cornellappdev.uplift.ui.theme.UpliftTheme
13+
import com.cornellappdev.uplift.ui.viewmodels.profile.CheckInViewModel
1114
import com.cornellappdev.uplift.util.LockScreenOrientation
1215
import dagger.hilt.android.AndroidEntryPoint
1316
import javax.inject.Inject
@@ -20,6 +23,8 @@ class MainActivity : ComponentActivity() {
2023
lateinit var injectedDatastoreRepository: DatastoreRepository
2124

2225

26+
private val checkInViewModel: CheckInViewModel by viewModels()
27+
2328
override fun onCreate(savedInstanceState: Bundle?) {
2429
super.onCreate(savedInstanceState)
2530
installSplashScreen()
@@ -33,7 +38,14 @@ class MainActivity : ComponentActivity() {
3338
}
3439
}
3540
}
36-
}
37-
3841

42+
override fun onResume() {
43+
super.onResume()
44+
checkInViewModel.startLocationUpdates(this)
45+
}
3946

47+
override fun onPause() {
48+
super.onPause()
49+
checkInViewModel.stopLocationUpdates()
50+
}
51+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
}

app/src/main/java/com/cornellappdev/uplift/data/repositories/LocationRepository.kt

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,49 +4,99 @@ import android.Manifest
44
import android.content.Context
55
import android.content.pm.PackageManager
66
import android.location.Location
7+
import android.os.Looper
78
import androidx.compose.runtime.MutableState
89
import androidx.compose.runtime.State
910
import androidx.compose.runtime.mutableStateOf
1011
import androidx.core.app.ActivityCompat
1112
import com.google.android.gms.location.FusedLocationProviderClient
13+
import com.google.android.gms.location.LocationCallback
14+
import com.google.android.gms.location.LocationRequest
15+
import com.google.android.gms.location.LocationResult
1216
import com.google.android.gms.location.LocationServices
17+
import com.google.android.gms.location.Priority
18+
import kotlinx.coroutines.flow.MutableStateFlow
19+
import kotlinx.coroutines.flow.StateFlow
20+
1321

1422
/**
1523
* Collection of all location data for the user.
1624
*/
1725
object LocationRepository {
1826
private lateinit var fusedLocationClient: FusedLocationProviderClient
19-
private val _currentLocation: MutableState<Location?> = mutableStateOf(null)
27+
private var callback: LocationCallback? = null
2028

2129
/**
22-
* Either is the current user's location, or null if the location has not yet
23-
* been initialized.
30+
* Compose State for UI (home cards can read this .value and recompose on location updates).
31+
* Null if not yet initialized.
2432
* */
25-
var currentLocation = (_currentLocation as State<Location?>)
33+
private val _currentLocationState: MutableState<Location?> = mutableStateOf(null)
34+
val currentLocation: State<Location?> = _currentLocationState
2635

2736
/**
28-
* Starts updating [currentLocation] to the user's current location.
37+
* StateFLow for ViewModels to be able to collect and react to location updates. Null if not yet
38+
* initialized.
39+
*/
40+
private val _currentLocationFlow = MutableStateFlow<Location?>(null)
41+
val currentLocationFlow: StateFlow<Location?> = _currentLocationFlow
42+
43+
/**
44+
* Initializes the fused location client, if hasn't already been.
2945
*/
3046
fun instantiate(context: Context) {
31-
fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
32-
updateLocation(context)
47+
if (!::fusedLocationClient.isInitialized){
48+
fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
49+
}
3350
}
3451

35-
36-
private fun updateLocation(context: Context) {
52+
/**
53+
* Updates [Location] every 30 seconds. Also updates [currentLocation] and [currentLocationFlow]
54+
* to the user's current [Location].
55+
*/
56+
fun startLocationUpdates(context: Context) {
3757
if (ActivityCompat.checkSelfPermission(
3858
context,
3959
Manifest.permission.ACCESS_FINE_LOCATION
4060
) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
4161
context,
4262
Manifest.permission.ACCESS_COARSE_LOCATION
4363
) != PackageManager.PERMISSION_GRANTED
44-
) {
45-
return
46-
}
64+
) return
65+
66+
if (callback != null) return
4767

48-
fusedLocationClient.lastLocation.addOnSuccessListener {
49-
_currentLocation.value = it
68+
instantiate(context)
69+
70+
val request = LocationRequest.Builder(
71+
Priority.PRIORITY_HIGH_ACCURACY,
72+
30_000L
73+
).build()
74+
75+
val cb = object : LocationCallback() {
76+
override fun onLocationResult(locationResult: LocationResult) {
77+
super.onLocationResult(locationResult)
78+
_currentLocationState.value = locationResult.lastLocation
79+
_currentLocationFlow.value = locationResult.lastLocation
80+
}
5081
}
82+
83+
callback = cb
84+
85+
fusedLocationClient.requestLocationUpdates(
86+
request,
87+
cb,
88+
Looper.getMainLooper()
89+
)
90+
}
91+
92+
/**
93+
* Stops location updates.
94+
*/
95+
fun stopLocationUpdates() {
96+
val cb = callback ?: return
97+
fusedLocationClient.removeLocationUpdates(cb)
98+
callback = null
5199
}
100+
101+
52102
}

0 commit comments

Comments
 (0)