diff --git a/app/build.gradle b/app/build.gradle index 2604ce37..fdc87cd4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,8 +48,8 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' buildConfigField("String", "GOOGLE_AUTH_CLIENT_ID", secretsProperties["GOOGLE_AUTH_CLIENT_ID"]) buildConfigField("String", "BACKEND_URL", secretsProperties['PROD_ENDPOINT']) - buildConfigField("boolean", "ONBOARDING_FLAG", "false") - buildConfigField("boolean", "CHECK_IN_FLAG", "false") + buildConfigField("boolean", "ONBOARDING_FLAG", "true") + buildConfigField("boolean", "CHECK_IN_FLAG", "true") } debug { buildConfigField("String", "BACKEND_URL", secretsProperties['DEV_ENDPOINT']) @@ -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/graphql/User.graphql b/app/src/main/graphql/User.graphql index 09439e63..ebfccc9e 100644 --- a/app/src/main/graphql/User.graphql +++ b/app/src/main/graphql/User.graphql @@ -1,14 +1,24 @@ fragment userFields on User { id email - name netId + name + encodedImage + activeStreak + maxStreak + streakStart + workoutGoal + lastGoalChange + lastStreak + totalGymDays } fragment workoutFields on Workout { id workoutTime userId + facilityId + gymName } mutation CreateUser($email: String!, $name: String!, $netId: String!) { diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index 2b3e050b..6d880076 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -378,9 +378,11 @@ type User { totalGymDays: Int! """ - The start date of the most recent active streak, up until the current date. + The start datetime of the most recent active streak (midnight of the day in local timezone), up until the current date. """ - streakStart: Date + streakStart: DateTime + + workoutHistory: [Workout] } type Giveaway { @@ -421,13 +423,6 @@ type Friendship { friend: User } -""" -The `Date` scalar type represents a Date -value as specified by -[iso8601](https://en.wikipedia.org/wiki/ISO_8601). -""" -scalar Date - type Workout { id: ID! diff --git a/app/src/main/java/com/cornellappdev/uplift/data/models/ProfileData.kt b/app/src/main/java/com/cornellappdev/uplift/data/models/ProfileData.kt new file mode 100644 index 00000000..b9d5a90e --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/models/ProfileData.kt @@ -0,0 +1,17 @@ +package com.cornellappdev.uplift.data.models + +import android.net.Uri + + +data class ProfileData( + val name: String, + val netId: String, + val encodedImage: String?, + val totalGymDays: Int, + val activeStreak: Int, + val maxStreak: Int, + val streakStart: String?, + val workoutGoal: Int, + val workouts: List, + val weeklyWorkoutDays: List +) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/data/models/UserInfo.kt b/app/src/main/java/com/cornellappdev/uplift/data/models/UserInfo.kt index d10aa9e1..c4720923 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/models/UserInfo.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/models/UserInfo.kt @@ -1,10 +1,17 @@ package com.cornellappdev.uplift.data.models import kotlinx.serialization.Serializable + @Serializable data class UserInfo( val id: String, val email: String, val name: String, val netId: String, + val encodedImage: String?, + val activeStreak: Int?, + val maxStreak: Int?, + val streakStart: String?, + val workoutGoal: Int?, + val totalGymDays: Int ) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/data/models/WorkoutDomain.kt b/app/src/main/java/com/cornellappdev/uplift/data/models/WorkoutDomain.kt new file mode 100644 index 00000000..5de644c1 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/models/WorkoutDomain.kt @@ -0,0 +1,6 @@ +package com.cornellappdev.uplift.data.models + +data class WorkoutDomain( + val gymName: String, + val timestamp: Long +) \ No newline at end of file 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 d0384d1f..52e380db 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 @@ -150,7 +150,14 @@ class CheckInRepository @Inject constructor( * Logs a completed workout to the backend. Returns true if the mutation succeeded, false otherwise. */ suspend fun logWorkoutFromCheckIn(gymId: Int): Boolean { - val userId = userInfoRepository.getUserIdFromDataStore()?.toIntOrNull() ?: return false + val userIdString = userInfoRepository.getUserIdFromDataStore() + val userId = userIdString?.toIntOrNull() + + if (userId == null) { + Log.e("CheckInRepository", "Missing or invalid userId in DataStore: $userIdString") + return false + } + val time = Instant.now().toString() return try { diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/ProfileRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/ProfileRepository.kt new file mode 100644 index 00000000..0d15d828 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/ProfileRepository.kt @@ -0,0 +1,110 @@ +package com.cornellappdev.uplift.data.repositories + +import android.util.Log +import com.apollographql.apollo.ApolloClient +import com.cornellappdev.uplift.GetUserByNetIdQuery +import com.cornellappdev.uplift.GetWeeklyWorkoutDaysQuery +import com.cornellappdev.uplift.GetWorkoutsByIdQuery +import com.cornellappdev.uplift.SetWorkoutGoalsMutation +import com.cornellappdev.uplift.data.models.ProfileData +import com.cornellappdev.uplift.data.models.WorkoutDomain +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class ProfileRepository @Inject constructor( + private val userInfoRepository: UserInfoRepository, + private val apolloClient: ApolloClient +) { + suspend fun getProfile(): Result = runCatching { + val netId = userInfoRepository.getNetIdFromDataStore() + ?: throw IllegalStateException("NetId missing") + + val userResponse = apolloClient.query( + GetUserByNetIdQuery(netId) + ).execute() + + if (userResponse.hasErrors()) { + Log.e("ProfileRepo", "User query errors: ${userResponse.errors}") + throw IllegalStateException("User query failed") + } + + val user = userResponse.data?.getUserByNetId?.firstOrNull()?.userFields + ?: throw IllegalStateException("User not found") + + val userId = user.id.toIntOrNull() + ?: throw IllegalStateException("Invalid user ID: ${user.id}") + + coroutineScope { + val workoutDeferred = async { + apolloClient.query(GetWorkoutsByIdQuery(userId)).execute() + } + val weeklyDeferred = async { + apolloClient.query(GetWeeklyWorkoutDaysQuery(userId)).execute() + } + + val workoutResponse = workoutDeferred.await() + if (workoutResponse.hasErrors()) { + Log.e("ProfileRepo", "Workout query errors: ${workoutResponse.errors}") + throw IllegalStateException("Workout query failed") + } + + val workouts = workoutResponse.data?.getWorkoutsById?.filterNotNull().orEmpty() + + val workoutDomain = workouts.map { + WorkoutDomain( + gymName = it.workoutFields.gymName, + timestamp = Instant.parse(it.workoutFields.workoutTime.toString()) + .toEpochMilli() + ) + } + + val weeklyResponse = weeklyDeferred.await() + if (weeklyResponse.hasErrors()) { + Log.e("ProfileRepo", "Weekly query errors: ${weeklyResponse.errors}") + throw IllegalStateException("Weekly workout days query failed") + } + + val weeklyDays = weeklyResponse.data?.getWeeklyWorkoutDays?.filterNotNull().orEmpty() + + ProfileData( + name = user.name, + netId = user.netId, + encodedImage = user.encodedImage, + totalGymDays = user.totalGymDays, + activeStreak = user.activeStreak, + maxStreak = user.maxStreak, + streakStart = user.streakStart?.toString(), + workoutGoal = user.workoutGoal ?: 0, + workouts = workoutDomain, + weeklyWorkoutDays = weeklyDays + ) + } + }.onFailure { e -> + Log.e("ProfileRepo", "Failed to load profile", e) + } + + suspend fun setWorkoutGoal(goal: Int): Result = runCatching { + val userId = userInfoRepository.getUserIdFromDataStore()?.toIntOrNull() + ?: throw IllegalStateException("Missing user ID") + + val response = apolloClient + .mutation( + SetWorkoutGoalsMutation( + id = userId, + workoutGoal = goal + ) + ) + .execute() + + if (response.hasErrors()) { + throw IllegalStateException("Goal update failed") + } + }.onFailure { e -> + Log.e("ProfileRepo", "Failed to update workout goal", e) + } +} diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt index b674864d..a02838ca 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt @@ -64,7 +64,14 @@ class UserInfoRepository @Inject constructor( else { Log.d("UserInfoRepository", "Skipping goal upload") } - storeUserFields(id, name, netId, email, skip, goal) + storeUserFields( + id = userFields.id, + username = userFields.name, + netId = userFields.netId, + email = userFields.email ?: email, + skip = skip, + goal = goal + ) Log.d("UserInfoRepositoryImpl", "User created successfully") return true } catch (e: Exception) { @@ -107,6 +114,27 @@ class UserInfoRepository @Inject constructor( } } + suspend fun syncUserToDataStore(netId: String): Boolean { + return try { + val user = getUserByNetId(netId) ?: return false + + storeUserFields( + id = user.id, + username = user.name, + netId = user.netId, + email = user.email, + skip = false, + goal = user.workoutGoal ?: 0 + ) + + Log.d("UserInfoRepositoryImpl", "Synced existing user to DataStore: ${user.id}") + true + } catch (e: Exception) { + Log.e("UserInfoRepositoryImpl", "Error syncing user to DataStore", e) + false + } + } + suspend fun getUserByNetId(netId: String): UserInfo? { try { val response = apolloClient.query( @@ -120,6 +148,12 @@ class UserInfoRepository @Inject constructor( name = user.name, email = user.email ?: "", netId = user.netId, + encodedImage = user.encodedImage, + activeStreak = user.activeStreak, + maxStreak = user.maxStreak, + workoutGoal = user.workoutGoal, + streakStart = user.streakStart?.toString(), + totalGymDays = user.totalGymDays ) } catch (e: Exception) { Log.e("UserInfoRepositoryImpl", "Error getting user by netId: $e") @@ -148,36 +182,6 @@ class UserInfoRepository @Inject constructor( firebaseAuth.signOut() } - private suspend fun storeId(id: String) { - dataStore.edit { preferences -> - preferences[PreferencesKeys.ID] = id - } - } - - private suspend fun storeUsername(username: String) { - dataStore.edit { preferences -> - preferences[PreferencesKeys.USERNAME] = username - } - } - - private suspend fun storeNetId(netId: String) { - dataStore.edit { preferences -> - preferences[PreferencesKeys.NETID] = netId - } - } - - private suspend fun storeEmail(email: String) { - dataStore.edit { preferences -> - preferences[PreferencesKeys.EMAIL] = email - } - } - - private suspend fun storeGoal(goal: Int) { - dataStore.edit { preferences -> - preferences[PreferencesKeys.GOAL] = goal - } - } - suspend fun storeSkip(skip: Boolean) { dataStore.edit { preferences -> preferences[PreferencesKeys.SKIP] = skip diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt index 5373026c..90a133ea 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt @@ -53,7 +53,6 @@ import com.cornellappdev.uplift.ui.viewmodels.classes.ClassDetailViewModel import com.cornellappdev.uplift.ui.viewmodels.gyms.GymDetailViewModel import com.cornellappdev.uplift.ui.viewmodels.nav.RootNavigationViewModel import com.cornellappdev.uplift.ui.viewmodels.profile.CheckInViewModel -import com.cornellappdev.uplift.util.ONBOARDING_FLAG import com.cornellappdev.uplift.ui.viewmodels.profile.ConfettiViewModel import com.cornellappdev.uplift.util.CHECK_IN_FLAG import com.cornellappdev.uplift.util.PRIMARY_BLACK @@ -77,8 +76,7 @@ fun MainNavigationWrapper( classDetailViewModel: ClassDetailViewModel = hiltViewModel(), rootNavigationViewModel: RootNavigationViewModel = hiltViewModel(), - ) { - +) { val rootNavigationUiState = rootNavigationViewModel.collectUiStateValue() val startDestination = rootNavigationUiState.startDestination @@ -97,7 +95,7 @@ fun MainNavigationWrapper( val items = listOfNotNull( BottomNavScreens.Home, BottomNavScreens.Classes, - BottomNavScreens.Profile.takeIf { ONBOARDING_FLAG } + BottomNavScreens.Profile ) systemUiController.setStatusBarColor(PRIMARY_YELLOW) diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/ProfileHeaderSection.kt b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/ProfileHeaderSection.kt index 2a156f57..9f2b8010 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/ProfileHeaderSection.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/ProfileHeaderSection.kt @@ -1,31 +1,23 @@ package com.cornellappdev.uplift.ui.components.profile import android.net.Uri -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.cornellappdev.uplift.R import com.cornellappdev.uplift.ui.components.onboarding.PhotoPicker import com.cornellappdev.uplift.ui.components.onboarding.ScreenType -import com.cornellappdev.uplift.util.GRAY01 import com.cornellappdev.uplift.util.GRAY04 import com.cornellappdev.uplift.util.montserratFamily @@ -34,7 +26,7 @@ fun ProfileHeaderSection( name: String, gymDays: Int, streaks: Int, - badges: Int, + netId: String, profilePictureUri: Uri?, onPhotoSelected: (Uri) -> Unit ){ @@ -48,7 +40,7 @@ fun ProfileHeaderSection( onPhotoSelected = onPhotoSelected, screenType = ScreenType.PROFILE ) - ProfileHeaderInfoDisplay(name, gymDays, streaks, badges) + ProfileHeaderInfoDisplay(name, gymDays, streaks, netId, modifier = Modifier.weight(1f)) } } @@ -57,34 +49,55 @@ private fun ProfileHeaderInfoDisplay( name: String, gymDays: Int, streaks: Int, - badges: Int + netID: String, + modifier: Modifier = Modifier ) { Column( + modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = name, - fontFamily = montserratFamily, - fontSize = 24.sp, - fontWeight = FontWeight.Bold - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = name, + modifier = Modifier.weight(1f, fill = false), + fontFamily = montserratFamily, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (netID.isNotBlank()){ + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "($netID)", + fontFamily = montserratFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Light, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween ) { ProfileHeaderInfo( label = "Gym Days", amount = gymDays ) + Spacer(modifier = Modifier.width(36.dp)) ProfileHeaderInfo( label = "Streaks", amount = streaks ) - ProfileHeaderInfo( - label = "Badges", - amount = badges - ) +// ProfileHeaderInfo( +// label = "Badges", +// amount = badges +// ) } } } @@ -115,11 +128,11 @@ private fun ProfileHeaderInfo(label: String, amount: Int) { @Composable private fun ProfileHeaderSectionPreview() { ProfileHeaderSection( - name = "John Doe", + name = "Melissa Velasquez", gymDays = 100, streaks = 15, - badges = 3, profilePictureUri = null, - onPhotoSelected = {} + onPhotoSelected = {}, + netId = "mv477" ) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/HistorySection.kt b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/HistorySection.kt index 2a614348..03d4552e 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/HistorySection.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/HistorySection.kt @@ -1,50 +1,78 @@ package com.cornellappdev.uplift.ui.components.profile.workouts +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusModifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.cornellappdev.uplift.R import com.cornellappdev.uplift.ui.components.profile.SectionTitleText import com.cornellappdev.uplift.util.GRAY01 import com.cornellappdev.uplift.util.montserratFamily +import com.cornellappdev.uplift.util.timeAgoString +import java.util.Calendar data class HistoryItem( val gymName: String, val time: String, - val dayOfWeek: String, val date: String, + val timestamp: Long, + val ago: String ) @Composable fun HistorySection( historyItems: List, - onClick : () -> Unit + onClick : () -> Unit, + modifier: Modifier = Modifier ) { Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center ) { - SectionTitleText("My History", onClick) - HistoryList(historyItems) + SectionTitleText("My Workout History", onClick) + Spacer(modifier = Modifier.height(12.dp)) + if (historyItems.isNotEmpty()) { + HistoryList( + historyItems = historyItems, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) + } else { + EmptyHistorySection() + } } } @Composable -private fun HistoryList(historyItems: List) { - Column { +private fun HistoryList( + historyItems: List, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { historyItems.take(5).forEachIndexed { index, historyItem -> HistoryItemRow(historyItem = historyItem) if (index != historyItems.size - 1) { @@ -60,26 +88,69 @@ private fun HistoryItemRow( ) { val gymName = historyItem.gymName val time = historyItem.time - val dayOfWeek = historyItem.dayOfWeek val date = historyItem.date + val ago = historyItem.ago + Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween ) { + Column(){ + Text( + text = gymName, + fontFamily = montserratFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + Text( + text = "$date · $time", + fontFamily = montserratFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = Color.Gray + ) + } Text( - text = gymName, + text = ago, fontFamily = montserratFamily, - fontSize = 14.sp, + fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Color.Black ) + } +} + +@Composable +private fun EmptyHistorySection(){ + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_dufflebag), + contentDescription = null, + modifier = Modifier + .width(65.dp) + .height(51.dp) + + ) + Spacer(modifier = Modifier.height(12.dp)) Text( - text = "$time · $dayOfWeek $date", + text = "No workouts yet.", fontFamily = montserratFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Light, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + Text( + text = "Head to a gym and check in!", + fontFamily = montserratFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, color = Color.Black ) } @@ -88,12 +159,13 @@ private fun HistoryItemRow( @Preview(showBackground = true) @Composable private fun HistorySectionPreview() { + val now = System.currentTimeMillis() val historyItems = listOf( - HistoryItem("Morrison", "11:00 PM", "Fri", "March 29, 2024"), - HistoryItem("Noyes", "1:00 PM","Fri", "March 29, 2024"), - HistoryItem("Teagle Up", "2:00 PM", "Fri", "March 29, 2024"), - HistoryItem("Teagle Down", "12:00 PM", "Fri", "March 29, 2024"), - HistoryItem("Helen Newman", "10:00 AM", "Fri", "March 29, 2024"), + HistoryItem("Morrison", "11:00 PM", "March 29, 2024", now - (1 * 24 * 60 * 60 * 1000), "1 day ago"), + HistoryItem("Noyes", "1:00 PM", "March 29, 2024", now - (3 * 24 * 60 * 60 * 1000), "2 days ago"), + HistoryItem("Teagle Up", "2:00 PM", "March 29, 2024", now - (7 * 24 * 60 * 60 * 1000), "1 day ago"), + HistoryItem("Teagle Down", "12:00 PM", "March 29, 2024", now - (15 * 24 * 60 * 60 * 1000), "1 day ago"), + HistoryItem("Helen Newman", "10:00 AM", "March 29, 2024", now, "Today"), ) Column( modifier = Modifier diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/WeeklyProgressTracker.kt b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/WeeklyProgressTracker.kt index 28e10813..5cdf7338 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/WeeklyProgressTracker.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/WeeklyProgressTracker.kt @@ -48,15 +48,23 @@ fun WeeklyProgressTracker( completedDays: List ) { val daysOfWeek = listOf("M", "T", "W", "Th", "F", "Sa", "Su") - val paddedCompletedDays = if (completedDays.size < daysOfWeek.size) { - completedDays + List(daysOfWeek.size - completedDays.size) { false } - } else { - completedDays + + if (daysOfMonth.size < daysOfWeek.size) { + return } + + val paddedCompletedDays = + if (completedDays.size < daysOfWeek.size) { + completedDays + List(daysOfWeek.size - completedDays.size) { false } + } else { + completedDays + } + val lastCompletedIndex = paddedCompletedDays.indexOfLast { it } Box(modifier = Modifier.fillMaxWidth()) { ConnectingLines(daysOfWeek, lastCompletedIndex) + DayProgressCirclesRow( dayProgressList = daysOfWeek.mapIndexed { index, dayOfWeek -> DayProgress( diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/WorkoutProgressArc.kt b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/WorkoutProgressArc.kt index be32012f..3f2c7010 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/WorkoutProgressArc.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/WorkoutProgressArc.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -52,9 +51,16 @@ fun WorkoutProgressArc( workoutsCompleted: Int, workoutGoal: Int, ) { - // Calculate progress percentage - val progress = (workoutsCompleted.toFloat() / workoutGoal.toFloat()).coerceIn(0f, 1f) + val isZero = workoutsCompleted <= 0 || workoutGoal <= 0 + val isComplete = workoutGoal in 1..workoutsCompleted + // Calculate progress percentage + val progress = when { + workoutGoal <= 0 -> 0f + workoutsCompleted <= 0 -> 0f + else -> (workoutsCompleted.toFloat() / workoutGoal.toFloat()) + .coerceIn(0f, 1f) + } // Setup animation val animatedProgress = remember { Animatable(0f) } @@ -74,16 +80,16 @@ fun WorkoutProgressArc( .height(132.dp) ) { // Draw the progress arc - ProgressArc(animatedProgress, workoutsCompleted, workoutGoal) - WorkoutFractionTextSection(workoutsCompleted, workoutGoal) + ProgressArc(animatedProgress, isZero, isComplete) + WorkoutFractionTextSection(workoutsCompleted, workoutGoal, isComplete) } } @Composable private fun ProgressArc( animatedProgress: Animatable, - workoutsCompleted: Int, - workoutGoal: Int + isZero: Boolean, + isComplete: Boolean ) { val startAngle = 180f; val maxSweepAngle = 180f; @@ -112,16 +118,29 @@ private fun ProgressArc( // Progress arc val progressAngle = maxSweepAngle * animatedProgress.value - drawProgressArc( - workoutsCompleted, - workoutGoal, - gradientBrush, - startAngle, - progressAngle, - topLeft, - arcSize, - strokeWidth - ) + if (progressAngle > 0f) { + if (isComplete) { + drawArc( + brush = gradientBrush, + startAngle = startAngle, + sweepAngle = progressAngle, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + } else { + drawArc( + color = PRIMARY_YELLOW, + startAngle = startAngle, + sweepAngle = progressAngle, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + } + } // Progress arc circle val angle = Math.toRadians((startAngle + progressAngle).toDouble()) @@ -134,7 +153,29 @@ private fun ProgressArc( val y = arcCenterY + (radius * sin(angle)).toFloat() // Outer circle - drawArcSliderOuterCircle(workoutsCompleted, workoutGoal, gradientBrush, dotRadius, x, y) + when { + isComplete -> { + drawCircle( + brush = gradientBrush, + radius = dotRadius, + center = Offset(x, y) + ) + } + isZero -> { + drawCircle( + color = GRAY03, + radius = dotRadius, + center = Offset(x, y) + ) + } + else -> { + drawCircle( + color = PRIMARY_YELLOW, + radius = dotRadius, + center = Offset(x, y) + ) + } + } // Inner circle drawCircle( @@ -145,77 +186,8 @@ private fun ProgressArc( } } -private fun DrawScope.drawProgressArc( - workoutsCompleted: Int, - workoutGoal: Int, - gradientBrush: Brush, - startAngle: Float, - progressAngle: Float, - topLeft: Offset, - arcSize: Size, - strokeWidth: Float -) { - if (workoutsCompleted == workoutGoal) { - drawArc( - brush = gradientBrush, - startAngle = startAngle, - sweepAngle = progressAngle, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = Stroke(width = strokeWidth, cap = StrokeCap.Round) - ) - } else { - drawArc( - color = PRIMARY_YELLOW, - startAngle = startAngle, - sweepAngle = progressAngle, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = Stroke(width = strokeWidth, cap = StrokeCap.Round) - ) - } -} - - -private fun DrawScope.drawArcSliderOuterCircle( - workoutsCompleted: Int, - workoutGoal: Int, - gradientBrush: Brush, - dotRadius: Float, - x: Float, - y: Float -) { - when (workoutsCompleted) { - workoutGoal -> { - drawCircle( - brush = gradientBrush, - radius = dotRadius, - center = Offset(x, y) - ) - } - - 0 -> { - drawCircle( - color = GRAY03, - radius = dotRadius, - center = Offset(x, y) - ) - } - - else -> { - drawCircle( - color = PRIMARY_YELLOW, - radius = dotRadius, - center = Offset(x, y) - ) - } - } -} - @Composable -private fun WorkoutFractionTextSection(workoutsCompleted: Int, workoutGoal: Int) { +private fun WorkoutFractionTextSection(workoutsCompleted: Int, workoutGoal: Int, isComplete: Boolean) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), @@ -225,7 +197,7 @@ private fun WorkoutFractionTextSection(workoutsCompleted: Int, workoutGoal: Int) verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - WorkoutsCompletedText(workoutsCompleted, workoutGoal) + WorkoutsCompletedText(workoutsCompleted, isComplete) Text( text = "/ $workoutGoal", @@ -237,7 +209,7 @@ private fun WorkoutFractionTextSection(workoutsCompleted: Int, workoutGoal: Int) ) } Text( - text = "Workouts this week", + text = "Days this week", fontSize = 14.sp, color = GRAY04, fontFamily = montserratFamily @@ -246,8 +218,8 @@ private fun WorkoutFractionTextSection(workoutsCompleted: Int, workoutGoal: Int) } @Composable -private fun WorkoutsCompletedText(workoutsCompleted: Int, workoutGoal: Int) { - if (workoutsCompleted == workoutGoal) { +private fun WorkoutsCompletedText(workoutsCompleted: Int, isComplete: Boolean) { + if (isComplete) { Text( text = "$workoutsCompleted", fontSize = 64.sp, diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/ProfileScreen.kt index 695c299c..c6a10e18 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/ProfileScreen.kt @@ -1,12 +1,13 @@ package com.cornellappdev.uplift.ui.screens.profile import android.annotation.SuppressLint +import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -15,10 +16,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color @@ -27,104 +27,73 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import com.cornellappdev.uplift.R -import com.cornellappdev.uplift.ui.components.general.UpliftTabRow import com.cornellappdev.uplift.ui.components.profile.workouts.GoalsSection import com.cornellappdev.uplift.ui.components.profile.workouts.HistoryItem import com.cornellappdev.uplift.ui.components.profile.workouts.HistorySection -import com.cornellappdev.uplift.ui.components.profile.workouts.MyRemindersSection import com.cornellappdev.uplift.ui.components.profile.ProfileHeaderSection import com.cornellappdev.uplift.ui.components.profile.workouts.ReminderItem +import com.cornellappdev.uplift.ui.viewmodels.profile.ProfileUiState +import com.cornellappdev.uplift.ui.viewmodels.profile.ProfileViewModel import com.cornellappdev.uplift.util.GRAY01 import com.cornellappdev.uplift.util.montserratFamily @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProfileScreen() { - /* TODO: Replace with call to viewmodel */ - val name = "John Doe" - /* TODO: Replace with call to viewmodel */ - val gymDays = 132 - /* TODO: Replace with call to viewmodel */ - val streaks = 14 - /* TODO: Replace with call to viewmodel */ - val badges = 6 - /* TODO: Replace with call to viewmodel */ - val profilePicture = null - /* TODO: Replace with call to viewmodel */ - val workoutsCompleted = 3 - /* TODO: Replace with call to viewmodel */ - val workoutGoal = 5 - /* TODO: Replace with call to viewmodel */ - val daysOfMonth = (25..31).toList() - /* TODO: Replace with call to viewmodel */ - val completedDays = listOf(false, true, true, false, true, false, false) - /* TODO: Replace with call to viewmodel */ - val reminderItems = listOf( - ReminderItem("Mon", "8:00 AM", "9:00 AM", true), - ReminderItem("Tue", "8:00 AM", "12:00 PM", false), - ReminderItem("Wed", "8:00 AM", "9:00 AM", true), - ReminderItem("Thu", "8:00 AM", "9:00 AM", false), - ReminderItem("Fri", "11:30 AM", "12:00 PM", true), - ) - /* TODO: Replace with call to viewmodel */ - val historyItems = listOf( - HistoryItem("Morrison", "11:00 PM", "Fri", "March 29, 2024"), - HistoryItem("Noyes", "1:00 PM", "Fri", "March 29, 2024"), - HistoryItem("Teagle Up", "2:00 PM", "Fri", "March 29, 2024"), - HistoryItem("Teagle Down", "12:00 PM", "Fri", "March 29, 2024"), - HistoryItem("Helen Newman", "10:00 AM", "Fri", "March 29, 2024"), - ) +fun ProfileScreen( + viewModel: ProfileViewModel = hiltViewModel() +) { + val uiState by viewModel.uiStateFlow.collectAsState() - var tabIndex by remember { mutableIntStateOf(0) } - val tabs = listOf("WORKOUTS", "ACHIEVEMENTS") + ProfileScreenContent(uiState,viewModel::toSettings,viewModel::toGoals, viewModel::toHistory) - val scrollState = rememberScrollState() +} +@Composable +private fun ProfileScreenContent( + uiState: ProfileUiState, + toSettings: () -> Unit, + toGoals: () -> Unit, + toHistory: () -> Unit +) { Scaffold( containerColor = Color.White, topBar = { - /* TODO: Replace {} with viewmodel nav call */ - ProfileScreenTopBar(navigateToSettings = {}) + ProfileScreenTopBar(navigateToSettings = toSettings) } ) { innerPadding -> Column( - verticalArrangement = Arrangement.spacedBy(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState) .padding( top = innerPadding.calculateTopPadding() + 24.dp, start = 16.dp, end = 16.dp, ) ) { - /* TODO: Replace {} with viewmodel function call */ ProfileHeaderSection( - name = name, - gymDays = gymDays, - streaks = streaks, - badges = badges, - profilePictureUri = profilePicture, - onPhotoSelected = {} + name = uiState.name, + gymDays = uiState.totalGymDays, + streaks = uiState.activeStreak, + profilePictureUri = uiState.profileImage, + onPhotoSelected = {}, + netId = uiState.netId + ) + WorkoutsSectionContent( + workoutsCompleted = uiState.workoutsCompleted, + workoutGoal = uiState.workoutGoal, + daysOfMonth = uiState.daysOfMonth, + completedDays = uiState.completedDays, + reminderItems= emptyList(), //implement + historyItems = uiState.historyItems, + navigateToGoalsSection = toGoals, + navigateToRemindersSection = { /* TODO: Replace {} with viewmodel nav call */ }, + navigateToHistorySection = toHistory ) - UpliftTabRow(tabIndex, tabs, onTabChange = { tabIndex = it }) - when (tabIndex) { - 0 -> WorkoutsSectionContent( - workoutsCompleted, - workoutGoal, - daysOfMonth, - completedDays, - reminderItems, - historyItems, - navigateToGoalsSection = { /* TODO: Replace {} with viewmodel nav call */ }, - navigateToRemindersSection = { /* TODO: Replace {} with viewmodel nav call */ }, - navigateToHistorySection = { /* TODO: Replace {} with viewmodel nav call */ } - ) - - 1 -> AchievementsSectionContent() - } } } @@ -142,21 +111,24 @@ private fun WorkoutsSectionContent( navigateToRemindersSection: () -> Unit, navigateToHistorySection: () -> Unit ) { - GoalsSection( - workoutsCompleted = workoutsCompleted, - workoutGoal = workoutGoal, - daysOfMonth = daysOfMonth, - completedDays = completedDays, - onClick = navigateToGoalsSection, - ) - MyRemindersSection( - reminderItems, - onClickHeader = navigateToRemindersSection, - ) - HistorySection( - historyItems = historyItems, - onClick = navigateToHistorySection, - ) + Column( + modifier = Modifier.fillMaxSize() + ) { + GoalsSection( + workoutsCompleted = workoutsCompleted, + workoutGoal = workoutGoal, + daysOfMonth = daysOfMonth, + completedDays = completedDays, + onClick = navigateToGoalsSection, + ) + Spacer(modifier = Modifier.height(24.dp)) + + HistorySection( + historyItems = historyItems, + onClick = navigateToHistorySection, + modifier = Modifier.weight(1f) + ) + } } //TODO: Implement AchievementsSection @@ -197,8 +169,23 @@ private fun ProfileScreenTopBar( ) } -@Preview(showBackground = true) +@Preview @Composable -private fun ProfileScreenPreview() { - ProfileScreen() +private fun ProfileScreenContentPreview() { + ProfileScreenContent( + uiState = ProfileUiState( + name = "Melissa Velasquez", + netId = "mv477", + totalGymDays = 0, + activeStreak = 0, + workoutGoal = 4, + historyItems = emptyList(), + daysOfMonth = listOf(10, 11, 12, 13, 14, 15, 16), + completedDays = listOf(false, false, false, false, false, false, false), + workoutsCompleted = 0 + ), + {}, + {}, + {} + ) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt index aa0b3dcf..bcb7babe 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt @@ -42,14 +42,20 @@ class LoginViewModel @Inject constructor( return@launch } when { - userInfoRepository.hasUser(netId) -> rootNavigationRepository.navigate( - UpliftRootRoute.Home - ) + userInfoRepository.hasUser(netId) -> { + val synced = userInfoRepository.syncUserToDataStore(netId) + if (synced) { + rootNavigationRepository.navigate(UpliftRootRoute.Home) + } else { + Log.e("Error", "Failed to sync existing user") + userInfoRepository.signOut() + } + } userInfoRepository.hasFirebaseUser() -> rootNavigationRepository.navigate( UpliftRootRoute.ProfileCreation ) - //TODO: Handle error + else -> { Log.e("Error", "Unexpected credential") userInfoRepository.signOut() 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 09c05160..0a17dac8 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 @@ -150,7 +150,12 @@ class CheckInViewModel @Inject constructor( ) } confettiRepository.showConfetti(ConfettiViewModel.ConfettiUiState()) - checkInRepository.logWorkoutFromCheckIn(gymIdInt) + val logged = checkInRepository.logWorkoutFromCheckIn(gymIdInt) + if (logged) { + Log.d(tag, "Workout successfully logged to backend") + } else { + Log.e(tag, "Workout failed to log to backend") + } } catch (e: Exception) { Log.e(tag, "Error checking in", e) } 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 new file mode 100644 index 00000000..6fb6d760 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt @@ -0,0 +1,161 @@ +package com.cornellappdev.uplift.ui.viewmodels.profile + +import android.net.Uri +import android.util.Log +import androidx.lifecycle.viewModelScope +import coil.util.CoilUtils.result +import com.cornellappdev.uplift.data.repositories.ProfileRepository +import com.cornellappdev.uplift.data.repositories.UserInfoRepository +import com.cornellappdev.uplift.ui.UpliftRootRoute +import com.cornellappdev.uplift.ui.components.profile.workouts.HistoryItem +import com.cornellappdev.uplift.ui.nav.RootNavigationRepository +import com.cornellappdev.uplift.ui.viewmodels.UpliftViewModel +import com.cornellappdev.uplift.util.timeAgoString +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject + +data class ProfileUiState( + val loading: Boolean = false, + val error: Boolean = false, + val name: String = "", + val netId: String = "", + val profileImage: Uri? = null, + val totalGymDays: Int = 0, + val activeStreak: Int = 0, + val maxStreak: Int = 0, + val streakStart: String? = null, + val workoutGoal: Int = 0, + val historyItems: List = emptyList(), + val daysOfMonth: List = emptyList(), + val completedDays: List = emptyList(), + val workoutsCompleted: Int = 0 +) + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val profileRepository: ProfileRepository, + private val rootNavigationRepository: RootNavigationRepository, +) : UpliftViewModel(ProfileUiState()) { + + private var loadingJob: Job? = null + + init { + reload() + } + + fun reload() { + if (loadingJob?.isActive == true) return + loadingJob = loadProfile() + + } + + + private fun loadProfile(): Job = viewModelScope.launch { + applyMutation { copy(loading = true, error = false) } + + val result = profileRepository.getProfile() + + val profile = result.getOrNull() + if (profile == null) { + Log.e("profile VM", "Failed to load profile", result.exceptionOrNull()) + applyMutation { copy(loading = false, error = true) } + return@launch + } + val historyItems = profile.workouts.map { + val workoutInstant = Instant.ofEpochMilli(it.timestamp) + val calendar = java.util.Calendar.getInstance().apply { + timeInMillis = it.timestamp + } + HistoryItem( + gymName = it.gymName, + time = formatTime.format(workoutInstant), + date = formatDate.format(workoutInstant), + timestamp = it.timestamp, + dayOfWeek = formatDayOfWeek.format(workoutInstant), + ago = calendar.timeAgoString() + ) + } + + val now = LocalDate.now() + val startOfWeek = now.with(DayOfWeek.MONDAY) + + val weekDates = (0..6).map { + startOfWeek.plusDays(it.toLong()) + } + + val daysOfMonth = weekDates.map { it.dayOfMonth } + + val completedDays = weekDates.map { date -> + profile.weeklyWorkoutDays.contains(date.toString()) + } + + val workoutsCompleted = profile.weeklyWorkoutDays.size + + applyMutation { + copy( + loading = false, + name = profile.name, + netId = profile.netId, + profileImage = profile.encodedImage?.let(Uri::parse), + totalGymDays = profile.totalGymDays, + activeStreak = profile.activeStreak, + maxStreak = profile.maxStreak, + streakStart = profile.streakStart, + workoutGoal = profile.workoutGoal, + historyItems = historyItems, + daysOfMonth = daysOfMonth, + completedDays = completedDays, + workoutsCompleted = workoutsCompleted + ) + + } + } + + fun updateWorkoutGoal(goal: Int) = viewModelScope.launch { + val result = profileRepository.setWorkoutGoal(goal) + + if (result.isSuccess) { + reload() + } else { + Log.e("profile VM", "Failed to update workout goal", result.exceptionOrNull()) + } + } + + fun toSettings() { + rootNavigationRepository.navigate(UpliftRootRoute.Settings) + } + + fun toGoals() { + // replace with the actual route once goals exists + } + + fun toHistory() { + // replace with the actual route once history exists + } + + + + private val formatTime = DateTimeFormatter + .ofPattern("h:mm a") + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) + + private val formatDate = DateTimeFormatter + .ofPattern("MMMM d, yyyy") + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) + + private val formatDayOfWeek = DateTimeFormatter + .ofPattern("EEE") + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) +} + diff --git a/app/src/main/java/com/cornellappdev/uplift/util/Functions.kt b/app/src/main/java/com/cornellappdev/uplift/util/Functions.kt index d3cf75ab..516c1da7 100644 --- a/app/src/main/java/com/cornellappdev/uplift/util/Functions.kt +++ b/app/src/main/java/com/cornellappdev/uplift/util/Functions.kt @@ -183,3 +183,38 @@ val startTimeComparator = { class1: UpliftClass, class2: UpliftClass -> class1.time.end.compareTo(class2.time.end) } } + +/** + * Returns a relative time string such as: + * "1 day ago", "2 weeks ago", "1 month ago", "1 year ago" + */ +fun Calendar.timeAgoString(): String { + val now = Calendar.getInstance() + + val diffMillis = now.timeInMillis - this.timeInMillis + if (diffMillis < 0) return "Today" + + val diffDays = diffMillis / (1000 * 60 * 60 * 24) + + val diffWeeks = diffDays / 7 + val diffMonths = diffDays / 30 + val diffYears = diffDays / 365 + + return when { + diffDays < 1 -> "Today" + + diffDays == 1L -> "1 day ago" + diffDays in 2L..6L -> "$diffDays days ago" + + diffWeeks == 1L -> "1 week ago" + diffWeeks in 2L..4L -> "$diffWeeks weeks ago" + + diffMonths == 1L -> "1 month ago" + diffMonths in 2L..11L -> "$diffMonths months ago" + + diffYears == 1L -> "1 year ago" + diffYears > 1L -> "$diffYears years ago" + + else -> "Today" + } +} diff --git a/app/src/main/res/drawable/ic_dufflebag.xml b/app/src/main/res/drawable/ic_dufflebag.xml new file mode 100644 index 00000000..71100405 --- /dev/null +++ b/app/src/main/res/drawable/ic_dufflebag.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + +