diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index da07aa68..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/app/build.gradle b/app/build.gradle index 466d8b21..2604ce37 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -127,6 +127,7 @@ dependencies { implementation 'com.google.firebase:firebase-auth' implementation 'com.google.firebase:firebase-messaging' implementation("com.google.firebase:firebase-analytics") + implementation "androidx.security:security-crypto:1.1.0-alpha06" } apollo { diff --git a/app/src/main/graphql/User.graphql b/app/src/main/graphql/User.graphql index b3765527..09439e63 100644 --- a/app/src/main/graphql/User.graphql +++ b/app/src/main/graphql/User.graphql @@ -33,7 +33,7 @@ query getUserByNetId($netId: String!) { } } -mutation SetWorkoutGoals($id: Int!, $workoutGoal: [String!]!) { +mutation SetWorkoutGoals($id: Int!, $workoutGoal: Int!) { setWorkoutGoals(userId: $id, workoutGoal: $workoutGoal) { ...userFields } @@ -43,4 +43,17 @@ mutation LogWorkout($facilityId: Int!, $workoutTime: DateTime!, $id: Int!) { logWorkout(facilityId: $facilityId, userId: $id, workoutTime: $workoutTime) { ...workoutFields } -} \ No newline at end of file +} + +mutation DeleteUser($userId: Int!) { + deleteUser(userId: $userId) { + ...userFields + } +} + +mutation LoginUser($netId: String!) { + loginUser(netId: $netId) { + accessToken + refreshToken + } +} 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/repositories/AuthInterceptor.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/AuthInterceptor.kt new file mode 100644 index 00000000..cbdbdff5 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/AuthInterceptor.kt @@ -0,0 +1,21 @@ +package com.cornellappdev.uplift.data.repositories + +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthInterceptor @Inject constructor( + private val tokenManager: TokenManager +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = tokenManager.getAccessToken() + val request = chain.request().newBuilder().apply { + if (token != null) { + addHeader("Authorization", "Bearer $token") + } + }.build() + return chain.proceed(request) + } +} diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/DatastoreRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/DatastoreRepository.kt index 65be80e0..49c11e93 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/DatastoreRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/DatastoreRepository.kt @@ -25,6 +25,7 @@ object PreferencesKeys { val USERNAME = stringPreferencesKey("username") val NETID = stringPreferencesKey("netId") val EMAIL = stringPreferencesKey("email") + val GOAL = intPreferencesKey("workoutGoal") val SKIP = booleanPreferencesKey("skip") val FCM_TOKEN = stringPreferencesKey("fcmToken") val DECLINED_NOTIFICATION_PERMISSION = diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt new file mode 100644 index 00000000..3c387aec --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt @@ -0,0 +1,65 @@ +package com.cornellappdev.uplift.data.repositories + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import androidx.core.content.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenManager @Inject constructor(@ApplicationContext private val context: Context) { + private val fileName = "encrypted_tokens" + + // Initialize EncryptedSharedPreferences + private val sharedPreferences: SharedPreferences? by lazy { + try { + createEncryptedPrefs() + } catch (e: Exception) { + Log.e("TokenManager", "Failed to initialize EncryptedSharedPreferences", e) + // Clear corrupted state + context.deleteSharedPreferences(fileName) + try { + // Could have failed due to previous corrupted state + // One more attempt after cleaning the corruption + createEncryptedPrefs() + } catch (retryException: Exception) { + // Probably broken return null + Log.e("TokenManager", "Failed to initialize EncryptedSharedPreferences again", retryException) + null + } + } + } + + private fun createEncryptedPrefs(): SharedPreferences { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + return EncryptedSharedPreferences.create( + context, + fileName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + fun saveTokens(accessToken: String, refreshToken: String) { + sharedPreferences?.edit { + putString("access_token", accessToken) + putString("refresh_token", refreshToken) + } + } + + fun getAccessToken(): String? = sharedPreferences?.getString("access_token", null) + + fun getRefreshToken(): String? = sharedPreferences?.getString("refresh_token", null) + + fun clearTokens() { + sharedPreferences?.edit { clear() } + } +} 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..b674864d 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 @@ -9,6 +9,8 @@ import androidx.datastore.preferences.core.Preferences import com.apollographql.apollo.ApolloClient import com.cornellappdev.uplift.CreateUserMutation import com.cornellappdev.uplift.GetUserByNetIdQuery +import com.cornellappdev.uplift.LoginUserMutation +import com.cornellappdev.uplift.SetWorkoutGoalsMutation import kotlinx.coroutines.flow.map; import kotlinx.coroutines.flow.firstOrNull import com.cornellappdev.uplift.data.models.UserInfo @@ -22,9 +24,10 @@ class UserInfoRepository @Inject constructor( private val firebaseAuth: FirebaseAuth, private val apolloClient: ApolloClient, private val dataStore: DataStore, + private val tokenManager: TokenManager ){ - suspend fun createUser(email: String, name: String, netId: String): Boolean { + suspend fun createUser(email: String, name: String, netId: String, skip: Boolean, goal: Int): Boolean { try{ val response = apolloClient.mutation( CreateUserMutation( @@ -33,12 +36,36 @@ class UserInfoRepository @Inject constructor( netId = netId, ) ).execute() - storeId(response.data?.createUser?.userFields?.let { storeId(it.id) }.toString()) - storeNetId(netId) - storeUsername(name) - storeEmail(email) - storeSkip(false) - Log.d("UserInfoRepositoryImpl", "User created successfully" + response.data) + val userFields = response.data?.createUser?.userFields + if (response.hasErrors() || userFields == null) { + Log.e("UserInfoRepository", "Server error: ${response.errors}") + return false + } + val loginResponse = apolloClient.mutation( + LoginUserMutation( + netId = netId + ) + ).execute() + val id = userFields.id + val loginData = loginResponse.data?.loginUser + if (loginData?.accessToken == null || loginData.refreshToken == null) { + Log.e("UserInfoRepository", "Login failed after creation: ${loginResponse.errors}") + return false + } + val accessToken = loginData.accessToken + val refreshToken = loginData.refreshToken + tokenManager.saveTokens(accessToken, refreshToken) + if (!skip) { + val numericId = id.toIntOrNull() + if (!uploadGoal(numericId, goal)) { + return false + } + } + else { + Log.d("UserInfoRepository", "Skipping goal upload") + } + storeUserFields(id, name, netId, email, skip, goal) + Log.d("UserInfoRepositoryImpl", "User created successfully") return true } catch (e: Exception) { Log.e("UserInfoRepositoryImpl", "Error creating user: $e") @@ -46,14 +73,48 @@ class UserInfoRepository @Inject constructor( } } + suspend fun uploadGoal(id:Int?, goal: Int): Boolean { + if (id == null) { + Log.e("UserInfoRepository", "Failed to set goal: non-numeric user ID '$id'") + return false + } + val goalResponse = apolloClient.mutation( + SetWorkoutGoalsMutation( + id = id, + workoutGoal = goal + ) + ) + .execute() + if (goalResponse.hasErrors()) { + Log.e("UserInfoRepository", "Failed to set goal: ${goalResponse.errors}") + return false + } + Log.d("UserInfoRepository", "Goal set successfully: $goal") + return true + } + + + suspend fun storeUserFields(id: String, username: String, netId: String, email: String, skip: Boolean, goal: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.ID] = id + preferences[PreferencesKeys.NETID] = netId + preferences[PreferencesKeys.USERNAME] = username + preferences[PreferencesKeys.EMAIL] = email + preferences[PreferencesKeys.SKIP] = skip + if (!skip) { + preferences[PreferencesKeys.GOAL] = goal + } + } + } + suspend fun getUserByNetId(netId: String): UserInfo? { try { val response = apolloClient.query( GetUserByNetIdQuery( netId = netId ) - ).executeV3() - val user = response.data?.getUserByNetId?.get(0)?.userFields ?: return null + ).execute() + val user = response.data?.getUserByNetId?.firstOrNull()?.userFields ?: return null return UserInfo( id = user.id, name = user.name, @@ -70,7 +131,7 @@ class UserInfoRepository @Inject constructor( return firebaseAuth.currentUser != null } - suspend fun getFirebaseUser(): FirebaseUser? { + fun getFirebaseUser(): FirebaseUser? { return firebaseAuth.currentUser } @@ -111,6 +172,12 @@ class UserInfoRepository @Inject constructor( } } + 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/di/AppModule.kt b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt index 521a0717..bbd77c6d 100644 --- a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt +++ b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt @@ -1,11 +1,14 @@ package com.cornellappdev.uplift.di import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.network.okHttpClient import com.cornellappdev.uplift.BuildConfig +import com.cornellappdev.uplift.data.repositories.AuthInterceptor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient import javax.inject.Singleton @@ -15,10 +18,19 @@ object AppModule { @Provides @Singleton - fun provideApolloClient(): ApolloClient { + fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .build() + } + + @Provides + @Singleton + fun provideApolloClient(okHttpClient: OkHttpClient): ApolloClient { return ApolloClient.Builder() .serverUrl(BuildConfig.BACKEND_URL) + .okHttpClient(okHttpClient) .build() } -} \ No newline at end of file +} 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..5373026c 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt @@ -46,12 +46,12 @@ import com.cornellappdev.uplift.ui.screens.profile.ProfileScreen import com.cornellappdev.uplift.ui.screens.profile.SettingsScreen import com.cornellappdev.uplift.ui.screens.reminders.CapacityReminderScreen import com.cornellappdev.uplift.ui.screens.reminders.MainReminderScreen +import com.cornellappdev.uplift.ui.screens.onboarding.WorkoutReminderOnboardingScreen import com.cornellappdev.uplift.ui.screens.report.ReportIssueScreen 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 @@ -237,6 +237,9 @@ fun MainNavigationWrapper( composable { ProfileCreationScreen() } + composable { + WorkoutReminderOnboardingScreen() + } composable { CapacityReminderScreen() } @@ -329,6 +332,10 @@ sealed class UpliftRootRoute { @Serializable data object Profile : UpliftRootRoute() + @Serializable + data object GoalsOnboarding : UpliftRootRoute() + + @Serializable data object CapacityReminders : UpliftRootRoute() diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/screens/onboarding/GoalOnboardingScreen.kt b/app/src/main/java/com/cornellappdev/uplift/ui/screens/onboarding/GoalOnboardingScreen.kt new file mode 100644 index 00000000..216838fe --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/ui/screens/onboarding/GoalOnboardingScreen.kt @@ -0,0 +1,25 @@ +package com.cornellappdev.uplift.ui.screens.onboarding + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.cornellappdev.uplift.ui.screens.reminders.WorkoutReminderScreen +import com.cornellappdev.uplift.ui.viewmodels.onboarding.ProfileCreationViewModel + +@Composable +fun WorkoutReminderOnboardingScreen( + viewModel: ProfileCreationViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle() + val goalValue = uiState.goal + + WorkoutReminderScreen( + goalValue = goalValue, + isOnboarding = true, + onGoalValueChange = { viewModel.updateGoals(it) }, + onBackClick = { viewModel.onBackClick() }, + onNext = { viewModel.onNext() }, + onSkip = { viewModel.onSkip() }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/screens/onboarding/ProfileCreationScreen.kt b/app/src/main/java/com/cornellappdev/uplift/ui/screens/onboarding/ProfileCreationScreen.kt index 72345093..98b0ec22 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/screens/onboarding/ProfileCreationScreen.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/screens/onboarding/ProfileCreationScreen.kt @@ -59,7 +59,7 @@ fun ProfileCreationScreen( ) = with(profileCreationViewModel.collectUiStateValue()) { ProfileCreationScreenContent( profileCreationViewModel::onPhotoSelected, - profileCreationViewModel::createUser, + profileCreationViewModel::navigateToGoals, name ) } @@ -68,7 +68,7 @@ fun ProfileCreationScreen( @OptIn(ExperimentalMaterial3Api::class) private fun ProfileCreationScreenContent( onPhotoSelected: (Uri) -> Unit, - createUser: () -> Unit, + navigateToGoals: () -> Unit, name: String, ) { val checkboxColors: CheckboxColors = @@ -145,7 +145,7 @@ private fun ProfileCreationScreenContent( ReadyToUplift(opacityModifier) UpliftButton( - onClick = createUser, + onClick = navigateToGoals, enabled = allChecked, text = if (allChecked) "Get started" else "Next", width = 144.dp, @@ -256,7 +256,7 @@ private fun InfoCheckboxRow( private fun ProfileCreationScreenPreview() { ProfileCreationScreenContent( onPhotoSelected = {}, - createUser = {}, + navigateToGoals = {}, name = "John Doe", ) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/screens/reminders/WorkoutReminderScreen.kt b/app/src/main/java/com/cornellappdev/uplift/ui/screens/reminders/WorkoutReminderScreen.kt index d36c5631..b8e0cbf2 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/screens/reminders/WorkoutReminderScreen.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/screens/reminders/WorkoutReminderScreen.kt @@ -23,11 +23,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.uplift.ui.components.general.UpliftTopBarWithBack import com.cornellappdev.uplift.ui.components.goalsetting.DeleteDialog import com.cornellappdev.uplift.ui.components.goalsetting.GoalSlider import com.cornellappdev.uplift.ui.components.goalsetting.WorkoutReminders import com.cornellappdev.uplift.ui.components.general.UpliftButton +import com.cornellappdev.uplift.ui.viewmodels.profile.SettingsViewModel import com.cornellappdev.uplift.util.GRAY04 import com.cornellappdev.uplift.util.montserratFamily @@ -43,10 +45,24 @@ data class Reminder( val enabled: Boolean ) +@Composable +fun WorkoutReminderSettingsScreen( + viewModel: SettingsViewModel = hiltViewModel(), +) { + /* TODO: Connect to view model */ + WorkoutReminderScreen( + goalValue = 1.0f, + isOnboarding = false, + // Pass callbacks to update the state defined above + onGoalValueChange = {}, + onBackClick = { viewModel.onBack() }, + ) +} + /** * @param reminders: list of reminders + * @param goalValue: the value of the goal slider * @param onRemindersChange: callback for when reminders are changed - * @param goalValue: value of the goal slider * @param onGoalValueChange: callback for when the goal slider value is changed * @param onBackClick: callback for when the back button is clicked * @param isOnboarding: whether the user is in onboarding @@ -56,20 +72,59 @@ data class Reminder( */ @Composable fun WorkoutReminderScreen( - /* TODO: Replace functions with viewmodel calls */ - reminders: List = emptyList(), - onRemindersChange: (List) -> Unit, + /* TODO: Add view model calls */ goalValue: Float, onGoalValueChange: (Float) -> Unit, + reminders: List = emptyList(), + onRemindersChange: (List) -> Unit = {}, onBackClick: () -> Unit, isOnboarding: Boolean = false, onNext: () -> Unit = {}, onSkip: () -> Unit = {} ) { + + // These states are local to this screen var selectedReminder by remember { mutableStateOf(null) } var addNewReminderState by remember { mutableStateOf(false) } var deleteDialogOpen by remember { mutableStateOf(false) } + WorkoutReminderContent( + reminders = reminders, + selectedReminder = selectedReminder, + addNewReminderState = addNewReminderState, + deleteDialogOpen = deleteDialogOpen, + goalValue = goalValue, + isOnboarding = isOnboarding, + // Pass callbacks to update the state defined above + onSelectedReminderChange = { selectedReminder = it }, + onAddNewReminderStateChange = { addNewReminderState = it }, + onDeleteDialogOpenChange = { deleteDialogOpen = it }, + onRemindersChange = onRemindersChange, + onGoalValueChange = onGoalValueChange, + onBackClick = onBackClick, + onNext = onNext, + onSkip = onSkip + ) +} + + +@Composable +private fun WorkoutReminderContent( + reminders: List, + selectedReminder: Reminder?, + addNewReminderState: Boolean, + deleteDialogOpen: Boolean, + goalValue: Float, + isOnboarding: Boolean, + onSelectedReminderChange: (Reminder?) -> Unit, + onAddNewReminderStateChange: (Boolean) -> Unit, + onDeleteDialogOpenChange: (Boolean) -> Unit, + onRemindersChange: (List) -> Unit, + onGoalValueChange: (Float) -> Unit = {}, + onBackClick: () -> Unit, + onNext: () -> Unit = {}, + onSkip: () -> Unit = {} +) { Scaffold( topBar = { UpliftTopBarWithBack( @@ -78,47 +133,50 @@ fun WorkoutReminderScreen( withBack = true ) }, + bottomBar = { + if (isOnboarding) { + OnboardingButtons(onNext, onSkip) + } + }, modifier = Modifier.fillMaxSize(), ) { padding -> Column( modifier = Modifier .background(color = Color.White) - .padding( - top = padding.calculateTopPadding(), - ) + .padding(padding) .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.SpaceBetween + .verticalScroll(rememberScrollState()) ) { - /* Groups the goal slider and workout reminders together */ - Column { - GoalSlider(value = goalValue, onValueChange = onGoalValueChange) + GoalSlider(value = goalValue, onValueChange = onGoalValueChange) + if (!isOnboarding) { WorkoutReminders( selectedReminder = selectedReminder, - onSelectedReminderChange = { selectedReminder = it }, + onSelectedReminderChange = onSelectedReminderChange, reminders = reminders, onRemindersChange = onRemindersChange, addNewReminderState = addNewReminderState, - onAddNewReminderStateChange = { addNewReminderState = it }, - openDelete = { deleteDialogOpen = true } + onAddNewReminderStateChange = onAddNewReminderStateChange, + openDelete = { onDeleteDialogOpenChange(true) } ) } - if (isOnboarding) OnboardingButtons(onNext, onSkip) + } + + if (deleteDialogOpen) { + DeleteDialog( + deleteDialogOpen = deleteDialogOpen, + onConfirm = { + selectedReminder?.let { reminder -> + onRemindersChange(reminders.filter { it != reminder }) + } + onDeleteDialogOpenChange(false) + onSelectedReminderChange(null) + onAddNewReminderStateChange(false) + }, + onDismiss = { onDeleteDialogOpenChange(false) } + ) } - DeleteDialog( - deleteDialogOpen = deleteDialogOpen, - onConfirm = { - selectedReminder?.let { reminder -> - onRemindersChange(reminders.filter { it != reminder }) - } - deleteDialogOpen = false - selectedReminder = null - addNewReminderState = false - }, - onDismiss = { deleteDialogOpen = false } - ) } } @@ -178,8 +236,9 @@ fun WorkoutReminderScreenPreview() { reminders.clear() reminders.addAll(updatedReminders) }, + isOnboarding = false, goalValue = sliderVal, onGoalValueChange = { sliderVal = it }, onBackClick = {}, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt index 8304a629..9331c5d9 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt @@ -7,37 +7,47 @@ import com.cornellappdev.uplift.data.repositories.UserInfoRepository import com.cornellappdev.uplift.ui.UpliftRootRoute import com.cornellappdev.uplift.ui.nav.RootNavigationRepository import com.cornellappdev.uplift.ui.viewmodels.UpliftViewModel +import com.google.firebase.auth.FirebaseUser import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject data class ProfileCreationUiState( + val user: FirebaseUser? = null, val name: String = "", - val imageUri: Uri? = null + val imageUri: Uri? = null, + val isGoalSkipped: Boolean = false, + val goal: Float = 0.0f // Goal slider val is stored as float but we could change this ) @HiltViewModel class ProfileCreationViewModel @Inject constructor( private val userInfoRepository: UserInfoRepository, private val rootNavigationRepository: RootNavigationRepository, -) : UpliftViewModel(ProfileCreationUiState()) { - - init { - viewModelScope.launch { - val user = userInfoRepository.getFirebaseUser() - val name = user?.displayName ?: "" - applyMutation { - copy(name = name) - } - } +) : UpliftViewModel( + userInfoRepository.getFirebaseUser().let { user -> + ProfileCreationUiState( + user = user, + name = user?.displayName ?: "" + ) } +) { - fun createUser() = viewModelScope.launch { - val user = userInfoRepository.getFirebaseUser() + private fun createUser() = viewModelScope.launch { + val state = getStateValue() + val user = state.user val name = user?.displayName ?: "" - val email = user?.email ?: "" - val netId = email.substring(0, email.indexOf('@')) - if (userInfoRepository.createUser(email, name, netId)) { + val email = user?.email + if (email.isNullOrBlank()) { + Log.e("Error", "Cannot create user: missing or blank email") + userInfoRepository.signOut() + return@launch + } + + val netId = email.substringBefore("@") + val isSkipped = state.isGoalSkipped + val goal = if (isSkipped) 0 else state.goal.toInt() + if (userInfoRepository.createUser(email, name, netId, isSkipped, goal)) { navigateToHome() } else { //TODO: Add error handling @@ -53,6 +63,36 @@ class ProfileCreationViewModel @Inject constructor( } } + fun onBackClick() { + rootNavigationRepository.navigateUp() + } + + fun updateGoals(newGoal: Float) { + applyMutation { + copy(goal = newGoal) + } + } + + fun onSkip() { + applyMutation { + copy(isGoalSkipped = true) + } + createUser() + } + + fun onNext() { + createUser() + } + + private fun navigateToProfileCreation() { + rootNavigationRepository.navigate(UpliftRootRoute.ProfileCreation) + } + + + fun navigateToGoals() { + rootNavigationRepository.navigate(UpliftRootRoute.GoalsOnboarding) + } + private fun navigateToHome() { rootNavigationRepository.navigate(UpliftRootRoute.Home) }