diff --git a/app/src/main/graphql/User.graphql b/app/src/main/graphql/User.graphql index b3765527..764ea9d9 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!) { @@ -33,7 +43,7 @@ query getUserByNetId($netId: String!) { } } -mutation SetWorkoutGoals($id: Int!, $workoutGoal: [String!]!) { +mutation SetWorkoutGoals($id: Int!, $workoutGoal: Int!) { setWorkoutGoals(userId: $id, workoutGoal: $workoutGoal) { ...userFields } diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index 9df7f3a7..2b3e050b 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -36,16 +36,6 @@ type Query { """ getAllReports: [Report] - """ - Get the workout goals of a user by ID. - """ - getWorkoutGoals(id: Int!): [String] - - """ - Get the current and max workout streak of a user. - """ - getUserStreak(id: Int!): JSONString - """ Get all facility hourly average capacities. """ @@ -358,16 +348,22 @@ type User { name: String! - activeStreak: Int + activeStreak: Int! + + maxStreak: Int! + + workoutGoal: Int - maxStreak: Int + lastGoalChange: DateTime - workoutGoal: [DayOfWeekGraphQLEnum] + lastStreak: Int! encodedImage: String giveaways: [Giveaway] + goalHistory: [WorkoutGoalHistory] + friendRequestsSent: [Friendship] friendRequestsReceived: [Friendship] @@ -375,22 +371,16 @@ type User { friendships: [Friendship] friends: [User] -} - -enum DayOfWeekGraphQLEnum { - MONDAY - - TUESDAY - - WEDNESDAY - - THURSDAY - - FRIDAY - SATURDAY + """ + Get the total number of gym days (unique workout days) for user. + """ + totalGymDays: Int! - SUNDAY + """ + The start date of the most recent active streak, up until the current date. + """ + streakStart: Date } type Giveaway { @@ -401,6 +391,18 @@ type Giveaway { users: [User] } +type WorkoutGoalHistory { + id: ID! + + userId: Int! + + workoutGoal: Int! + + effectiveAt: DateTime! + + user: User +} + type Friendship { id: ID! @@ -419,6 +421,13 @@ 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! @@ -427,12 +436,9 @@ type Workout { userId: Int! facilityId: Int! -} -""" -JSON String -""" -scalar JSONString + gymName: String! +} type HourlyAverageCapacity { id: ID! @@ -448,6 +454,22 @@ type HourlyAverageCapacity { history: [Float]! } +enum DayOfWeekGraphQLEnum { + MONDAY + + TUESDAY + + WEDNESDAY + + THURSDAY + + FRIDAY + + SATURDAY + + SUNDAY +} + type CapacityReminder { id: ID! @@ -518,7 +540,7 @@ type Mutation { """ Set a user's workout goals. """ - setWorkoutGoals("The ID of the user." userId: Int!, "The new workout goal for the user in terms of days of the week." workoutGoal: [String]!): User + setWorkoutGoals("The ID of the user." userId: Int!, "The new workout goal for the user in terms of number of days per week." workoutGoal: Int!): User """ Log a user's workout. @@ -545,6 +567,11 @@ type Mutation { """ createReport(createdAt: DateTime!, description: String!, gymId: Int!, issue: String!): CreateReport + """ + Deletes a report by ID. + """ + deleteReport(reportId: Int!): Report + """ Deletes a user by ID. """ 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/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..0e5818cb --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/ProfileRepository.kt @@ -0,0 +1,129 @@ +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 java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +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 +) + +data class WorkoutDomain( + val gymName: String, + val timestamp: Long +) + +@Singleton +class ProfileRepository @Inject constructor( + private val userInfoRepository: UserInfoRepository, + private val apolloClient: ApolloClient +) { + suspend fun getProfile(): Result { + return try{ + val netId = userInfoRepository.getNetIdFromDataStore() + ?: return Result.failure(Exception("NetId missing")) + + val userResponse = apolloClient.query( + GetUserByNetIdQuery(netId) + ).execute() + + if (userResponse.hasErrors()) { + Log.e("ProfileRepo", "User query errors: ${userResponse.errors}") + return Result.failure(IllegalStateException("User query failed")) + } + + val user = userResponse.data?.getUserByNetId?.firstOrNull()?.userFields + ?: return Result.failure(IllegalStateException("User not found")) + + val userId = user.id.toIntOrNull() + ?: return Result.failure(IllegalStateException("Invalid user ID: ${user.id}")) + + val workoutResponse = apolloClient + .query(GetWorkoutsByIdQuery(userId)) + .execute() + + if (workoutResponse.hasErrors()) { + Log.e("ProfileRepo", "Workout query errors: ${workoutResponse.errors}") + } + + val workouts = if (workoutResponse.hasErrors()) { + emptyList() + } else { + workoutResponse.data?.getWorkoutsById?.filterNotNull() ?: emptyList() + } + + val workoutDomain = workouts.map { + WorkoutDomain( + gymName = it.workoutFields.gymName, + timestamp = Instant.parse(it.workoutFields.workoutTime.toString()).toEpochMilli() + ) + } + + val weeklyResponse = apolloClient.query(GetWeeklyWorkoutDaysQuery(userId)).execute() + if (weeklyResponse.hasErrors()) { + Log.e("ProfileRepo", "Weekly query errors=${weeklyResponse.errors}") + } + + val weeklyDays = if (weeklyResponse.hasErrors()) { + emptyList() + } else { + weeklyResponse.data?.getWeeklyWorkoutDays?.filterNotNull() ?: emptyList() + } + + Result.success( + 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 + ) + ) + } catch (e: Exception) { + Log.e("ProfileRepo", "Failed to load profile", e) + Result.failure(e) + } + } + + suspend fun setWorkoutGoal(userId: Int, goal: Int): Result { + return try { + val response = apolloClient + .mutation( + SetWorkoutGoalsMutation( + id = userId, + workoutGoal = goal + ) + ) + .execute() + + if (response.hasErrors()) { + Result.failure(Exception("Goal update failed")) + } else { + Result.success(Unit) + } + + } catch (e: Exception) { + Result.failure(e) + } + } +} \ No newline at end of file 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 c0166b90..69f02ca0 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 @@ -33,10 +33,13 @@ class UserInfoRepository @Inject constructor( netId = netId, ) ).execute() - storeId(response.data?.createUser?.userFields?.let { storeId(it.id) }.toString()) - storeNetId(netId) - storeUsername(name) - storeEmail(email) + + val createdUser = response.data?.createUser?.userFields ?: return false + + storeId(createdUser.id) + storeNetId(createdUser.netId) + storeUsername(createdUser.name) + storeEmail(createdUser.email ?: email) storeSkip(false) Log.d("UserInfoRepositoryImpl", "User created successfully" + response.data) return true @@ -46,6 +49,24 @@ class UserInfoRepository @Inject constructor( } } + suspend fun syncUserToDataStore(netId: String): Boolean { + return try { + val user = getUserByNetId(netId) ?: return false + + storeId(user.id) + storeNetId(user.netId) + storeUsername(user.name) + storeEmail(user.email) + storeSkip(false) + + 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( @@ -53,12 +74,18 @@ class UserInfoRepository @Inject constructor( netId = netId ) ).executeV3() - val user = response.data?.getUserByNetId?.get(0)?.userFields ?: return null + val user = response.data?.getUserByNetId?.firstOrNull()?.userFields ?: return null return UserInfo( id = user.id, 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") 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 ab6ac345..5ee8c4c6 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt @@ -51,9 +51,7 @@ import com.cornellappdev.uplift.ui.screens.report.ReportSubmittedScreen 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.CheckInUiState 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 +75,10 @@ fun MainNavigationWrapper( classDetailViewModel: ClassDetailViewModel = hiltViewModel(), rootNavigationViewModel: RootNavigationViewModel = hiltViewModel(), - ) { +) { + val confettiViewModel: ConfettiViewModel = hiltViewModel() + val checkInViewModel: CheckInViewModel = hiltViewModel() val rootNavigationUiState = rootNavigationViewModel.collectUiStateValue() val startDestination = rootNavigationUiState.startDestination @@ -97,7 +97,7 @@ fun MainNavigationWrapper( val items = listOfNotNull( BottomNavScreens.Home, BottomNavScreens.Classes, - BottomNavScreens.Profile.takeIf { ONBOARDING_FLAG } + BottomNavScreens.Profile ) systemUiController.setStatusBarColor(PRIMARY_YELLOW) @@ -241,7 +241,7 @@ fun MainNavigationWrapper( CapacityReminderScreen() } composable { - ProfileScreen() + ProfileScreen(toSettings = {}, toGoals = {}, toHistory = {}) } composable { MainReminderScreen() 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..067480b2 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,42 +1,71 @@ 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.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 ) @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()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + HistoryList(historyItems) + } + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + EmptyHistorySection() + } + } } @@ -60,26 +89,72 @@ private fun HistoryItemRow( ) { val gymName = historyItem.gymName val time = historyItem.time - val dayOfWeek = historyItem.dayOfWeek val date = historyItem.date + val calendar = Calendar.getInstance().apply { + timeInMillis = historyItem.timestamp + } + val ago = calendar.timeAgoString() + 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_bag), + contentDescription = null, + modifier = Modifier + .width(64.99967.dp) + .height(50.8181.dp) + + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "No workouts yet.", + fontFamily = montserratFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) Text( - text = "$time · $dayOfWeek $date", + text = "Head to a gym and check in!", fontFamily = montserratFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Light, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, color = Color.Black ) } @@ -88,12 +163,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) ), + HistoryItem("Noyes", "1:00 PM", "March 29, 2024", now - (3 * 24 * 60 * 60 * 1000)), + HistoryItem("Teagle Up", "2:00 PM", "March 29, 2024", now - (7 * 24 * 60 * 60 * 1000)), + HistoryItem("Teagle Down", "12:00 PM", "March 29, 2024", now - (15 * 24 * 60 * 60 * 1000)), + HistoryItem("Helen Newman", "10:00 AM", "March 29, 2024", now), ) 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..a870c287 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 @@ -52,9 +52,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 > 0 && workoutsCompleted >= workoutGoal + // 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 +81,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 +119,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 +154,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( @@ -215,7 +257,7 @@ private fun DrawScope.drawArcSliderOuterCircle( } @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 +267,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 +279,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 +288,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..156f9800 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,116 +16,80 @@ 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 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 androidx.hilt.navigation.compose.hiltViewModel 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.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(), + toSettings:() -> Unit, + toGoals:() -> Unit, + toHistory:() -> Unit +) { +// var tabIndex by remember { mutableIntStateOf(0) } +// val tabs = listOf("WORKOUTS", "ACHIEVEMENTS") - var tabIndex by remember { mutableIntStateOf(0) } - val tabs = listOf("WORKOUTS", "ACHIEVEMENTS") + val uiState by viewModel.uiStateFlow.collectAsState() - val scrollState = rememberScrollState() + LaunchedEffect(Unit) { + viewModel.reload() + } 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?.let { Uri.parse(it) }, + 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 +107,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 @@ -196,9 +164,3 @@ private fun ProfileScreenTopBar( } ) } - -@Preview(showBackground = true) -@Composable -private fun ProfileScreenPreview() { - ProfileScreen() -} \ 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..72451fa6 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt @@ -0,0 +1,133 @@ +package com.cornellappdev.uplift.ui.viewmodels.profile + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.cornellappdev.uplift.data.repositories.ProfileRepository +import com.cornellappdev.uplift.data.repositories.UserInfoRepository +import com.cornellappdev.uplift.ui.components.profile.workouts.HistoryItem +import com.cornellappdev.uplift.ui.viewmodels.UpliftViewModel +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: String? = 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 userInfoRepository: UserInfoRepository, +) : UpliftViewModel(ProfileUiState()) { + + private var loadingJob: Job? = null + + 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() + + if (result.isSuccess) { + val profile = result.getOrNull()!! + + val historyItems = profile.workouts.map { + HistoryItem( + gymName = it.gymName, + time = formatTime.format( + Instant.ofEpochMilli(it.timestamp) + ), + date = formatDate.format( + Instant.ofEpochMilli(it.timestamp) + ), + timestamp = it.timestamp + ) + } + + 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, + totalGymDays = profile.totalGymDays, + activeStreak = profile.activeStreak, + maxStreak = profile.maxStreak, + streakStart = profile.streakStart, + workoutGoal = profile.workoutGoal, + historyItems = historyItems, + daysOfMonth = daysOfMonth, + completedDays = completedDays, + workoutsCompleted = workoutsCompleted + ) + } + } else { + Log.e("profile VM", "Failed to load profile", result.exceptionOrNull()) + applyMutation { copy(loading = false, error = true) } + } + + } + + + fun updateWorkoutGoal(goal: Int) = viewModelScope.launch { + val userId = userInfoRepository.getUserIdFromDataStore()?.toIntOrNull() ?: return@launch + val result = profileRepository.setWorkoutGoal(userId, goal) + + if (result.isSuccess) { + reload() + } else { + Log.e("profile VM", "Failed to update workout goal", result.exceptionOrNull()) + } + } + + 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()) +} \ No newline at end of file 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..0988bc52 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 2..6 -> "$diffDays days ago" + + diffWeeks == 1L -> "1 week ago" + diffWeeks in 2..4 -> "$diffWeeks weeks ago" + + diffMonths == 1L -> "1 month ago" + diffMonths in 2..11 -> "$diffMonths months ago" + + diffYears == 1L -> "1 year ago" + diffYears > 1L -> "$diffYears years ago" + + else -> "Today" + } +} diff --git a/app/src/main/res/drawable/ic_bag.png b/app/src/main/res/drawable/ic_bag.png new file mode 100644 index 00000000..41b1efe9 Binary files /dev/null and b/app/src/main/res/drawable/ic_bag.png differ