diff --git a/app/src/androidTest/java/com/android/periodpals/services/GPSServiceImplInstrumentedTest.kt b/app/src/androidTest/java/com/android/periodpals/services/GPSServiceImplInstrumentedTest.kt index 65c3d5c0d..1a39df547 100644 --- a/app/src/androidTest/java/com/android/periodpals/services/GPSServiceImplInstrumentedTest.kt +++ b/app/src/androidTest/java/com/android/periodpals/services/GPSServiceImplInstrumentedTest.kt @@ -9,8 +9,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.location.Location +import com.android.periodpals.model.location.UserLocationViewModel import com.android.periodpals.model.user.AuthenticationUserData -import com.android.periodpals.model.user.UserViewModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After @@ -36,7 +36,7 @@ class GPSServiceImplInstrumentedTest { private lateinit var activity: ComponentActivity private lateinit var gpsService: GPSServiceImpl private lateinit var authenticationViewModel: AuthenticationViewModel - private lateinit var userViewModel: UserViewModel + private lateinit var userLocationViewModel: UserLocationViewModel // Default location private val defaultLat = Location.DEFAULT_LOCATION.latitude @@ -45,7 +45,7 @@ class GPSServiceImplInstrumentedTest { @Before fun setup() { authenticationViewModel = mock(AuthenticationViewModel::class.java) - userViewModel = mock(UserViewModel::class.java) + userLocationViewModel = mock(UserLocationViewModel::class.java) scenario = ActivityScenario.launch(ComponentActivity::class.java) @@ -55,7 +55,7 @@ class GPSServiceImplInstrumentedTest { scenario.onActivity { activity -> this.activity = activity - gpsService = GPSServiceImpl(this.activity, authenticationViewModel, userViewModel) + gpsService = GPSServiceImpl(this.activity, authenticationViewModel, userLocationViewModel) } // Once the GPSService has been initialized, set its state to resumed diff --git a/app/src/main/java/com/android/periodpals/MainActivity.kt b/app/src/main/java/com/android/periodpals/MainActivity.kt index 39ece6c31..aad89efa4 100644 --- a/app/src/main/java/com/android/periodpals/MainActivity.kt +++ b/app/src/main/java/com/android/periodpals/MainActivity.kt @@ -24,6 +24,8 @@ import com.android.periodpals.model.authentication.AuthenticationModelSupabase import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.chat.ChatViewModel import com.android.periodpals.model.location.LocationViewModel +import com.android.periodpals.model.location.UserLocationModelSupabase +import com.android.periodpals.model.location.UserLocationViewModel import com.android.periodpals.model.timer.TimerManager import com.android.periodpals.model.timer.TimerRepositorySupabase import com.android.periodpals.model.timer.TimerViewModel @@ -90,6 +92,9 @@ class MainActivity : ComponentActivity() { private val userModel = UserRepositorySupabase(supabaseClient) private val userViewModel = UserViewModel(userModel) + private val userLocationModel = UserLocationModelSupabase(supabaseClient) + private val userLocationViewModel = UserLocationViewModel(userLocationModel) + private val alertModel = AlertModelSupabase(supabaseClient) private val alertViewModel = AlertViewModel(alertModel) @@ -100,7 +105,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - gpsService = GPSServiceImpl(this, authenticationViewModel, userViewModel) + gpsService = GPSServiceImpl(this, authenticationViewModel, userLocationViewModel) pushNotificationsService = PushNotificationsServiceImpl(this, authenticationViewModel, userViewModel) timerManager = TimerManager(this) @@ -115,10 +120,7 @@ class MainActivity : ComponentActivity() { GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(this) // Set up the OfflinePlugin for offline storage - val offlinePluginFactory = - StreamOfflinePluginFactory( - appContext = applicationContext, - ) + val offlinePluginFactory = StreamOfflinePluginFactory(appContext = applicationContext) val statePluginFactory = StreamStatePluginFactory(config = StatePluginConfig(), appContext = this) @@ -180,7 +182,7 @@ class MainActivity : ComponentActivity() { */ fun userAuthStateLogic( authenticationViewModel: AuthenticationViewModel, - navigationActions: NavigationActions + navigationActions: NavigationActions, ) { when (authenticationViewModel.userAuthenticationState.value) { is UserAuthenticationState.SuccessIsLoggedIn -> navigationActions.navigateTo(Screen.PROFILE) diff --git a/app/src/main/java/com/android/periodpals/model/location/UserLocationDto.kt b/app/src/main/java/com/android/periodpals/model/location/UserLocationDto.kt new file mode 100644 index 000000000..24b40d0f9 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/model/location/UserLocationDto.kt @@ -0,0 +1,16 @@ +package com.android.periodpals.model.location + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Data Transfer Object (DTO) representing a user's location. + * + * @property uid The unique identifier of the user. + * @property location The geographical location of the user. + */ +@Serializable +data class UserLocationDto( + @SerialName("uid") val uid: String, + @SerialName("locationGIS") val location: LocationGIS, +) diff --git a/app/src/main/java/com/android/periodpals/model/location/UserLocationModel.kt b/app/src/main/java/com/android/periodpals/model/location/UserLocationModel.kt new file mode 100644 index 000000000..7cce07a7a --- /dev/null +++ b/app/src/main/java/com/android/periodpals/model/location/UserLocationModel.kt @@ -0,0 +1,31 @@ +package com.android.periodpals.model.location + +/** Interface representing a model for user location. */ +interface UserLocationModel { + + /** + * Creates a new location. + * + * @param locationDto The [Location] data transfer object to be inserted. + * @param onSuccess A callback function to be invoked upon successful operation. + * @param onFailure A callback function to be invoked with an `Exception` if the operation fails. + */ + suspend fun create( + locationDto: UserLocationDto, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit, + ) + + /** + * Inserts or updates a location. + * + * @param locationDto The [Location] data transfer object to be inserted or updated. + * @param onSuccess A callback function to be invoked upon successful operation. + * @param onFailure A callback function to be invoked with an `Exception` if the operation fails. + */ + suspend fun update( + locationDto: UserLocationDto, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit, + ) +} diff --git a/app/src/main/java/com/android/periodpals/model/location/UserLocationModelSupabase.kt b/app/src/main/java/com/android/periodpals/model/location/UserLocationModelSupabase.kt new file mode 100644 index 000000000..02496f72d --- /dev/null +++ b/app/src/main/java/com/android/periodpals/model/location/UserLocationModelSupabase.kt @@ -0,0 +1,70 @@ +package com.android.periodpals.model.location + +import android.util.Log +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.postgrest.postgrest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Implementation of the UserLocationModel interface using Supabase. + * + * @property supabase The Supabase client used for database operations. + */ +class UserLocationModelSupabase(private val supabase: SupabaseClient) : UserLocationModel { + + companion object { + private const val TAG = "UserLocationModelSupabase" + private const val LOCATIONS = "locations" + } + + /** + * Creates a new location in the Supabase database. + * + * @param locationDto The [LocationGIS] data transfer object to be inserted. + * @param onSuccess A callback function to be invoked upon successful operation. + * @param onFailure A callback function to be invoked with an Exception if the operation fails. + */ + override suspend fun create( + locationDto: UserLocationDto, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit, + ) { + Log.d(TAG, "create: sending location dto: $locationDto") + try { + withContext(Dispatchers.IO) { + supabase.postgrest[LOCATIONS].insert(locationDto) + Log.d(TAG, "create: success") + onSuccess() + } + } catch (e: Exception) { + Log.e(TAG, "create: fail to create location of user ${locationDto.uid}: ${e.message}") + onFailure(e) + } + } + + /** + * Updates a location in the Supabase database. + * + * @param locationDto The [LocationGIS] data transfer object to be inserted or updated. + * @param onSuccess A callback function to be invoked upon successful operation. + * @param onFailure A callback function to be invoked with an Exception if the operation fails. + */ + override suspend fun update( + locationDto: UserLocationDto, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit, + ) { + Log.d(TAG, "update: sending location dto: $locationDto") + try { + withContext(Dispatchers.IO) { + supabase.postgrest[LOCATIONS].update(locationDto) { filter { eq("uid", locationDto.uid) } } + Log.d(TAG, "update: success") + onSuccess() + } + } catch (e: Exception) { + Log.e(TAG, "update: fail to update location of user ${locationDto.uid}: ${e.message}") + onFailure(e) + } + } +} diff --git a/app/src/main/java/com/android/periodpals/model/location/UserLocationViewModel.kt b/app/src/main/java/com/android/periodpals/model/location/UserLocationViewModel.kt new file mode 100644 index 000000000..7942290e1 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/model/location/UserLocationViewModel.kt @@ -0,0 +1,70 @@ +package com.android.periodpals.model.location + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +class UserLocationViewModel(private val userLocationModel: UserLocationModel) : ViewModel() { + + companion object { + private const val TAG = "UserLocationViewModel" + } + + /** + * Create a user location, or update it if it does already exists. + * + * Note: This implementation does not use upsert for security reasons. + * + * @param uid The unique identifier of the user. + * @param location The new [LocationGIS] of the user. + * @param onSuccess A callback function to be invoked upon successful operation. + * @param onFailure A callback function to be invoked with an `Exception` if the operation fails. + */ + fun uploadUserLocation( + uid: String, + location: LocationGIS, + onSuccess: () -> Unit = { Log.d(TAG, "uploadUserLocation success callback") }, + onFailure: (Exception) -> Unit = { e: Exception -> + Log.d(TAG, "uploadUserLocation failure callback: ${e.message}") + }, + ) { + val locationDto = UserLocationDto(uid = uid, location = location) + viewModelScope.launch { + // try to create a new location + Log.d(TAG, "uploadUserLocation: trying to create location $locationDto for user $uid") + userLocationModel.create( + locationDto = locationDto, + onSuccess = { + Log.d(TAG, "uploadUserLocation: create user location successful") + onSuccess() + }, + onFailure = { e: Exception -> + Log.d(TAG, "createUserLocation: location already exists, updating instead") + update(locationDto, onSuccess, onFailure) + }, + ) + } + } + + private fun update( + locationDto: UserLocationDto, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit, + ) { + Log.d(TAG, "update: trying to update location $locationDto") + viewModelScope.launch { + userLocationModel.update( + locationDto = locationDto, + onSuccess = { + Log.d(TAG, "update: update user location successful") + onSuccess() + }, + onFailure = { e: Exception -> + Log.d(TAG, "update: failed to upsert location of user ${locationDto.uid}: ${e.message}") + onFailure(e) + }, + ) + } + } +} diff --git a/app/src/main/java/com/android/periodpals/model/user/User.kt b/app/src/main/java/com/android/periodpals/model/user/User.kt index f6f043de9..8e3683c63 100644 --- a/app/src/main/java/com/android/periodpals/model/user/User.kt +++ b/app/src/main/java/com/android/periodpals/model/user/User.kt @@ -1,9 +1,5 @@ package com.android.periodpals.model.user -import com.android.periodpals.model.location.Location -import com.android.periodpals.model.location.LocationGIS -import com.android.periodpals.model.location.parseLocationGIS - /** * Data class representing a user. * @@ -13,7 +9,6 @@ import com.android.periodpals.model.location.parseLocationGIS * @property dob The date of birth of the user. * @property preferredDistance The preferred radius distance for receiving alerts. * @property fcmToken The Firebase Cloud Messaging token for the user (optional). - * @property locationGIS The geographic location of the user. Default is the default location. */ data class User( val name: String, @@ -22,7 +17,6 @@ data class User( val dob: String, val preferredDistance: Int, val fcmToken: String? = null, - val locationGIS: LocationGIS = parseLocationGIS(Location.DEFAULT_LOCATION), ) { /** * Converts the User object to a UserDto object. @@ -37,7 +31,6 @@ data class User( dob = this.dob, preferred_distance = this.preferredDistance, fcm_token = this.fcmToken, - locationGIS = this.locationGIS, ) } } diff --git a/app/src/main/java/com/android/periodpals/model/user/UserDto.kt b/app/src/main/java/com/android/periodpals/model/user/UserDto.kt index 8595f4f58..742d7189d 100644 --- a/app/src/main/java/com/android/periodpals/model/user/UserDto.kt +++ b/app/src/main/java/com/android/periodpals/model/user/UserDto.kt @@ -1,6 +1,5 @@ package com.android.periodpals.model.user -import com.android.periodpals.model.location.LocationGIS import kotlinx.serialization.Serializable /** @@ -12,7 +11,6 @@ import kotlinx.serialization.Serializable * @property dob The age of the user. * @property preferred_distance The preferred radius distance for receiving alerts. * @property fcm_token The Firebase Cloud Messaging token for the user (optional). - * @property locationGIS The geographic location of the user. */ @Serializable data class UserDto( @@ -22,7 +20,6 @@ data class UserDto( val dob: String, val preferred_distance: Int, val fcm_token: String? = null, - val locationGIS: LocationGIS, ) { /** * Converts this UserDto to a User object. @@ -37,7 +34,6 @@ data class UserDto( dob = this.dob, preferredDistance = this.preferred_distance, fcmToken = this.fcm_token, - locationGIS = this.locationGIS, ) } } diff --git a/app/src/main/java/com/android/periodpals/model/user/UserModelSupabase.kt b/app/src/main/java/com/android/periodpals/model/user/UserModelSupabase.kt index 394e882b5..10442a8cc 100644 --- a/app/src/main/java/com/android/periodpals/model/user/UserModelSupabase.kt +++ b/app/src/main/java/com/android/periodpals/model/user/UserModelSupabase.kt @@ -69,7 +69,6 @@ class UserRepositorySupabase(private val supabase: SupabaseClient) : UserReposit dob = user.dob, preferred_distance = user.preferredDistance, fcm_token = user.fcmToken, - locationGIS = user.locationGIS, ) supabase.postgrest[USERS].insert(userDto) } diff --git a/app/src/main/java/com/android/periodpals/resources/C.kt b/app/src/main/java/com/android/periodpals/resources/C.kt index 3b9260a70..2f8a0c9cd 100644 --- a/app/src/main/java/com/android/periodpals/resources/C.kt +++ b/app/src/main/java/com/android/periodpals/resources/C.kt @@ -175,21 +175,10 @@ object C { object SettingsScreen { const val SCREEN = "settingsScreen" - const val NOTIFICATIONS_CONTAINER = "notificationsContainer" - const val THEME_CONTAINER = "themeContainer" + const val REMARK_CONTAINER = "remarkContainer" + const val REMARK_TEXT = "remarkText" + const val SLIDER_CONTAINER = "sliderContainer" const val ACCOUNT_MANAGEMENT_CONTAINER = "accountManagementContainer" - const val NOTIFICATIONS_DESCRIPTION = "notificationDescription" - const val ORGANIC_DESCRIPTION = "organicDescription" - const val PALS_TEXT = "palsText" - const val PALS_SWITCH = "palsSwitch" - const val HORIZONTAL_DIVIDER = "horizontalDivider" - const val PADS_TEXT = "padsText" - const val PADS_SWITCH = "padsSwitch" - const val TAMPONS_TEXT = "tamponsText" - const val TAMPONS_SWITCH = "tamponsSwitch" - const val ORGANIC_TEXT = "organicText" - const val ORGANIC_SWITCH = "organicSwitch" - const val THEME_DROP_DOWN_MENU_BOX = "themeDropdownMenuBox" const val THEME_DROP_DOWN_MENU = "themeDropdownMenu" const val PASSWORD_TEXT = "passwordText" const val PASSWORD_ICON = "passwordIcon" diff --git a/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt b/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt index 1ab6fe93a..636e070a6 100644 --- a/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt +++ b/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt @@ -11,8 +11,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.app.ActivityCompat import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.location.Location +import com.android.periodpals.model.location.UserLocationViewModel import com.android.periodpals.model.location.parseLocationGIS -import com.android.periodpals.model.user.UserViewModel import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest @@ -51,7 +51,7 @@ private enum class REQUEST_TYPE { class GPSServiceImpl( private val activity: ComponentActivity, private val authenticationViewModel: AuthenticationViewModel, - private val userViewModel: UserViewModel, + private val userLocationViewModel: UserLocationViewModel, ) : GPSService { private var _location = MutableStateFlow(Location.DEFAULT_LOCATION) val location = _location.asStateFlow() @@ -199,17 +199,18 @@ class GPSServiceImpl( private fun uploadUserLocation() { Log.d(TAG_UPLOAD_LOCATION, "Uploading user location") authenticationViewModel.loadAuthenticationUserData( - onFailure = { Log.d(TAG_UPLOAD_LOCATION, "Authentication data is null") }) - userViewModel.loadUser( - authenticationViewModel.authUserData.value!!.uid, onSuccess = { - val newUser = - userViewModel.user.value?.copy(locationGIS = parseLocationGIS(_location.value)) - if (newUser != null) { - userViewModel.saveUser(user = newUser) + if (authenticationViewModel.authUserData.value == null) { + Log.e(TAG_UPLOAD_LOCATION, "User data is null") + return@loadAuthenticationUserData } - Log.d(TAG_UPLOAD_LOCATION, "success callback: user location uploaded") - }) + val uid = authenticationViewModel.authUserData.value?.uid!! + val location = parseLocationGIS(_location.value) + Log.d(TAG_UPLOAD_LOCATION, "Uploading location: ${_location.value}") + userLocationViewModel.uploadUserLocation(uid = uid, location = location) + }, + onFailure = { Log.e(TAG_UPLOAD_LOCATION, "Failed to upload user location") }, + ) } /** diff --git a/app/src/main/java/com/android/periodpals/ui/settings/SettingsScreen.kt b/app/src/main/java/com/android/periodpals/ui/settings/SettingsScreen.kt index 7bab71623..5107d02b6 100644 --- a/app/src/main/java/com/android/periodpals/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/android/periodpals/ui/settings/SettingsScreen.kt @@ -8,7 +8,6 @@ import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.fillMaxSize @@ -22,29 +21,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout -import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Key -import androidx.compose.material.icons.outlined.LightMode -import androidx.compose.material.icons.outlined.PhoneAndroid import androidx.compose.material.icons.outlined.SentimentVeryDissatisfied import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -59,28 +49,17 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.android.periodpals.R import com.android.periodpals.model.authentication.AuthenticationViewModel +import com.android.periodpals.model.user.User import com.android.periodpals.model.user.UserViewModel +import com.android.periodpals.resources.C.Tag.ProfileScreens.CreateProfileScreen import com.android.periodpals.resources.C.Tag.SettingsScreen -import com.android.periodpals.resources.ComponentColor.getMenuItemColors -import com.android.periodpals.resources.ComponentColor.getMenuTextFieldColors -import com.android.periodpals.resources.ComponentColor.getSwitchColors import com.android.periodpals.resources.ComponentColor.getTertiaryCardColors +import com.android.periodpals.ui.components.SliderMenu import com.android.periodpals.ui.navigation.NavigationActions import com.android.periodpals.ui.navigation.Screen import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens - -// Themes -private const val THEME_SYSTEM = "System" -private const val THEME_LIGHT = "Light Mode" -private const val THEME_DARK = "Dark Mode" - -// Dropdown choices -private val THEME_DROPDOWN_CHOICES = - listOf( - listOf(THEME_SYSTEM, Icons.Outlined.PhoneAndroid), - listOf(THEME_LIGHT, Icons.Outlined.LightMode), - listOf(THEME_DARK, Icons.Outlined.DarkMode)) +import kotlin.math.roundToInt // Log messages private const val LOG_SETTINGS_TAG = "SettingsScreen" @@ -98,7 +77,6 @@ private const val LOG_SETTINGS_TAG = "SettingsScreen" * @param authenticationViewModel The ViewModel that handles authentication logic. * @param navigationActions The navigation actions that can be performed in the app. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( userViewModel: UserViewModel, @@ -106,22 +84,19 @@ fun SettingsScreen( navigationActions: NavigationActions, ) { - // notifications states - var receiveNotifications by remember { mutableStateOf(true) } - var padsNotifications by remember { mutableStateOf(true) } - var tamponsNotifications by remember { mutableStateOf(true) } - var organicNotifications by remember { mutableStateOf(true) } - - // theme states - var expanded by remember { mutableStateOf(false) } - var theme by remember { mutableStateOf(THEME_SYSTEM) } - var icon by remember { mutableStateOf(Icons.Outlined.PhoneAndroid) } - // delete account dialog state var showDialog by remember { mutableStateOf(false) } val context = LocalContext.current + var sliderPosition by remember { + if (userViewModel.user.value == null) { + mutableFloatStateOf(500f) + } else { + mutableFloatStateOf(userViewModel.user.value!!.preferredDistance.toFloat()) + } + } + // delete account dialog logic if (showDialog) { DeleteAccountDialog( @@ -155,105 +130,40 @@ fun SettingsScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = - Arrangement.spacedBy(MaterialTheme.dimens.small2, Alignment.CenterVertically), + Arrangement.spacedBy(MaterialTheme.dimens.small3, Alignment.CenterVertically), ) { - // notification section - SettingsContainer(testTag = SettingsScreen.NOTIFICATIONS_CONTAINER) { - SettingsSwitchRow( - text = context.getString(R.string.settings_notif_pals), - isChecked = receiveNotifications, - onCheckedChange = { receiveNotifications = it }, - textTestTag = SettingsScreen.PALS_TEXT, - switchTestTag = SettingsScreen.PALS_SWITCH, + // Remark Section + SettingsContainer(testTag = SettingsScreen.REMARK_CONTAINER) { + Text( + text = context.getString(R.string.notifications_and_location_text), + style = MaterialTheme.typography.labelMedium, + modifier = + Modifier.wrapContentHeight() + .fillMaxWidth() + .testTag(SettingsScreen.REMARK_TEXT) + .padding(top = MaterialTheme.dimens.small2), + textAlign = TextAlign.Center, ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.testTag(SettingsScreen.HORIZONTAL_DIVIDER)) - SettingsDescription( - text = context.getString(R.string.settings_comment_notifications), - testTag = SettingsScreen.NOTIFICATIONS_DESCRIPTION) - SettingsSwitchRow( - text = context.getString(R.string.settings_notif_pads), - isChecked = receiveNotifications && padsNotifications, - onCheckedChange = { padsNotifications = it }, - textTestTag = SettingsScreen.PADS_TEXT, - switchTestTag = SettingsScreen.PADS_SWITCH) - SettingsSwitchRow( - text = context.getString(R.string.settings_notif_tampons), - isChecked = receiveNotifications && tamponsNotifications, - onCheckedChange = { tamponsNotifications = it }, - textTestTag = SettingsScreen.TAMPONS_TEXT, - switchTestTag = SettingsScreen.TAMPONS_SWITCH) - SettingsDescription( - context.getString(R.string.settings_comment_organic), - SettingsScreen.ORGANIC_DESCRIPTION) - SettingsSwitchRow( - text = context.getString(R.string.settings_notif_organic), - isChecked = receiveNotifications && organicNotifications, - onCheckedChange = { organicNotifications = it }, - textTestTag = SettingsScreen.ORGANIC_TEXT, - switchTestTag = SettingsScreen.ORGANIC_SWITCH) } - // theme section - SettingsContainer(testTag = SettingsScreen.THEME_CONTAINER) { - ExposedDropdownMenuBox( - modifier = Modifier.testTag(SettingsScreen.THEME_DROP_DOWN_MENU_BOX), - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - TextField( - modifier = Modifier.menuAnchor().fillMaxWidth().wrapContentHeight(), - textStyle = MaterialTheme.typography.labelLarge, - value = theme, - onValueChange = {}, - label = { - Text( - context.getString(R.string.settings_theme_label), - style = MaterialTheme.typography.labelMedium) - }, - singleLine = true, - readOnly = true, - leadingIcon = { Icon(icon, contentDescription = null) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - colors = getMenuTextFieldColors(), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.wrapContentSize().testTag(SettingsScreen.THEME_DROP_DOWN_MENU), - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) { - THEME_DROPDOWN_CHOICES.forEach { option -> - DropdownMenuItem( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), - text = { - Text( - text = option[0] as String, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier.padding(top = MaterialTheme.dimens.small2).wrapContentHeight(), - color = MaterialTheme.colorScheme.onSurface, - ) - }, - onClick = { - theme = option[0] as String - icon = option[1] as ImageVector - expanded = false - }, - leadingIcon = { - Icon( - option[1] as ImageVector, - contentDescription = null, - ) - }, - colors = getMenuItemColors(), - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } + // Slider Section + SettingsContainer(testTag = SettingsScreen.SLIDER_CONTAINER) { + SliderMenu(sliderPosition) { + sliderPosition = (it / 100).roundToInt() * 100f + sliderLogic(sliderPosition, userViewModel) } + + Text( + text = context.getString(R.string.create_profile_radius_explanation_text), + style = MaterialTheme.typography.labelMedium, + modifier = + Modifier.wrapContentHeight() + .fillMaxWidth() + .testTag(CreateProfileScreen.FILTER_RADIUS_EXPLANATION_TEXT) + .padding(top = MaterialTheme.dimens.small2), + textAlign = TextAlign.Center, + ) } // account management section @@ -335,62 +245,6 @@ private fun SettingsContainer(testTag: String, content: @Composable () -> Unit) } } -/** - * A composable function that displays a description in the settings screen. - * - * @param text the text to be displayed in the description. - * @param testTag the test tag for the description. - */ -@Composable -private fun SettingsDescription(text: String, testTag: String) { - Box(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { - Text( - text, - textAlign = TextAlign.Start, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.fillMaxWidth().wrapContentHeight().testTag(testTag), - color = MaterialTheme.colorScheme.onSurface, - ) - } -} - -/** - * A composable function that displays a row with a switch in the settings screen. - * - * @param text The text to be displayed in the row. - * @param isChecked The state of the switch. - * @param onCheckedChange The function to be called when the switch is toggled. - * @param textTestTag The test tag for the text. - * @param switchTestTag The test tag for the switch. - */ -@Composable -private fun SettingsSwitchRow( - text: String, - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, - textTestTag: String, - switchTestTag: String -) { - Row( - modifier = Modifier.fillMaxWidth().wrapContentHeight(), - horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text, - modifier = - Modifier.padding(top = MaterialTheme.dimens.small2) - .wrapContentHeight() - .testTag(textTestTag), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface) - Switch( - checked = isChecked, - onCheckedChange = onCheckedChange, - colors = getSwitchColors(), - modifier = Modifier.testTag(switchTestTag), - ) - } -} - /** * A composable function that displays a row with an icon in the settings screen. * @@ -484,33 +338,38 @@ private fun DeleteAccountDialog( onSuccess = { Log.d( LOG_SETTINGS_TAG, "user data loaded successfully, deleting the user") - userViewModel.deleteUser( - authenticationViewModel.authUserData.value!!.uid, + authenticationViewModel.logOut( onSuccess = { - Handler(Looper.getMainLooper()) - .post { // used to show the Toast on the main thread - Toast.makeText( - context, - context.getString( - R.string.settings_toast_success_delete), - Toast.LENGTH_SHORT) - .show() - } - Log.d(LOG_SETTINGS_TAG, "Account deleted successfully") - navigationActions.navigateTo(Screen.SIGN_IN) + Log.d(LOG_SETTINGS_TAG, "Sign out successful") + userViewModel.deleteUser( + authenticationViewModel.authUserData.value!!.uid, + onSuccess = { + Handler(Looper.getMainLooper()) + .post { // used to show the Toast on the main thread + Toast.makeText( + context, + context.getString( + R.string.settings_toast_success_delete), + Toast.LENGTH_SHORT) + .show() + } + Log.d(LOG_SETTINGS_TAG, "Account deleted successfully") + navigationActions.navigateTo(Screen.SIGN_IN) + }, + onFailure = { + Handler(Looper.getMainLooper()) + .post { // used to show the Toast on the main thread + Toast.makeText( + context, + context.getString( + R.string.settings_toast_failure_delete), + Toast.LENGTH_SHORT) + .show() + } + Log.d(LOG_SETTINGS_TAG, "Failed to delete account") + }) }, - onFailure = { - Handler(Looper.getMainLooper()) - .post { // used to show the Toast on the main thread - Toast.makeText( - context, - context.getString( - R.string.settings_toast_failure_delete), - Toast.LENGTH_SHORT) - .show() - } - Log.d(LOG_SETTINGS_TAG, "Failed to delete account") - }) + onFailure = { Log.d(LOG_SETTINGS_TAG, "Failed to sign out") }) }, onFailure = { Handler(Looper.getMainLooper()) @@ -556,3 +415,31 @@ private fun DeleteAccountDialog( } } } + +/** + * Function that updates the user's preferred distance when the slider is moved. + * + * @param sliderPosition The position of the slider. + * @param userViewModel The ViewModel that handles user data. + */ +fun sliderLogic( + sliderPosition: Float, + userViewModel: UserViewModel, +) { + + userViewModel.user.value?.let { user -> + val newUser = + User( + name = user.name, + dob = user.dob, + description = user.description, + imageUrl = user.imageUrl, + preferredDistance = sliderPosition.toInt(), + ) + + userViewModel.saveUser( + newUser, + onSuccess = { Log.d(LOG_SETTINGS_TAG, "User updated successfully") }, + onFailure = { Log.d(LOG_SETTINGS_TAG, "Failed to update user") }) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5564231f7..72eda136c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,15 +48,7 @@ My Settings - Notify me when a pal needs ... - Which are ... - - Pals’ Notifications - Pads - Tampons - Organic - - Theme + To enable/disable notifications or location, please go to your phone\'s settings Change Password Sign Out diff --git a/app/src/test/java/com/android/periodpals/model/location/UserLocationDtoTest.kt b/app/src/test/java/com/android/periodpals/model/location/UserLocationDtoTest.kt new file mode 100644 index 000000000..c6b5b64e6 --- /dev/null +++ b/app/src/test/java/com/android/periodpals/model/location/UserLocationDtoTest.kt @@ -0,0 +1,31 @@ +package com.android.periodpals.model.location + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +class UserLocationDtoTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun serializeUserLocationDto() { + val location = LocationGIS("Point", listOf(12.34, 56.78)) + val userLocationDto = UserLocationDto("user123", location) + val jsonString = json.encodeToString(userLocationDto) + val expectedJson = + """{"uid":"user123","locationGIS":{"type":"Point","coordinates":[12.34,56.78]}}""" + assertEquals(expectedJson, jsonString) + } + + @Test + fun deserializeUserLocationDto() { + val jsonString = + """{"uid":"user123","locationGIS":{"type":"Point","coordinates":[12.34,56.78]}}""" + val userLocationDto = json.decodeFromString(jsonString) + val expectedLocation = LocationGIS("Point", listOf(12.34, 56.78)) + val expectedUserLocationDto = UserLocationDto("user123", expectedLocation) + assertEquals(expectedUserLocationDto, userLocationDto) + } +} diff --git a/app/src/test/java/com/android/periodpals/model/location/UserLocationModelSupabaseTest.kt b/app/src/test/java/com/android/periodpals/model/location/UserLocationModelSupabaseTest.kt new file mode 100644 index 000000000..09cba05f7 --- /dev/null +++ b/app/src/test/java/com/android/periodpals/model/location/UserLocationModelSupabaseTest.kt @@ -0,0 +1,110 @@ +package com.android.periodpals.model.location + +import io.github.jan.supabase.createSupabaseClient +import io.github.jan.supabase.postgrest.Postgrest +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondBadRequest +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +class UserLocationModelSupabaseTest { + private lateinit var userLocationModelSupabase: UserLocationModelSupabase + + private val supabaseClientSuccess = + createSupabaseClient("", "") { + httpEngine = MockEngine { _ -> + respond( + content = + """{"uid":"user123","locationGIS":{"type":"Point","coordinates":[12.34,56.78]}}""", + status = HttpStatusCode.OK, + ) + } + install(Postgrest) + } + + private val supabaseClientFailure = + createSupabaseClient("", "") { + httpEngine = MockEngine { _ -> respondBadRequest() } + install(Postgrest) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + userLocationModelSupabase = UserLocationModelSupabase(supabaseClientSuccess) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun createLocationSuccess() = runTest { + val location = LocationGIS("Point", listOf(12.34, 56.78)) + val userLocationDto = UserLocationDto("user123", location) + var result = false + + userLocationModelSupabase.create( + locationDto = userLocationDto, + onSuccess = { result = true }, + onFailure = { fail("Should not call onFailure") }, + ) + assert(result) + } + + @Test + fun createLocationFailure() = runTest { + userLocationModelSupabase = UserLocationModelSupabase(supabaseClientFailure) + val location = LocationGIS("Point", listOf(12.34, 56.78)) + val userLocationDto = UserLocationDto("user123", location) + var onFailureCalled = false + + userLocationModelSupabase.create( + locationDto = userLocationDto, + onSuccess = { fail("Should not call onSuccess") }, + onFailure = { onFailureCalled = true }, + ) + assert(onFailureCalled) + } + + @Test + fun updateLocationSuccess() = runTest { + val location = LocationGIS("Point", listOf(12.34, 56.78)) + val userLocationDto = UserLocationDto("user123", location) + var result = false + + userLocationModelSupabase.update( + locationDto = userLocationDto, + onSuccess = { result = true }, + onFailure = { fail("Should not call onFailure") }, + ) + assert(result) + } + + @Test + fun updateLocationFailure() = runTest { + userLocationModelSupabase = UserLocationModelSupabase(supabaseClientFailure) + val location = LocationGIS("Point", listOf(12.34, 56.78)) + val userLocationDto = UserLocationDto("user123", location) + var onFailureCalled = false + + userLocationModelSupabase.update( + locationDto = userLocationDto, + onSuccess = { fail("Should not call onSuccess") }, + onFailure = { onFailureCalled = true }, + ) + assert(onFailureCalled) + } +} diff --git a/app/src/test/java/com/android/periodpals/model/location/UserLocationViewModelTest.kt b/app/src/test/java/com/android/periodpals/model/location/UserLocationViewModelTest.kt new file mode 100644 index 000000000..f0c9a6394 --- /dev/null +++ b/app/src/test/java/com/android/periodpals/model/location/UserLocationViewModelTest.kt @@ -0,0 +1,112 @@ +package com.android.periodpals.model.location + +import com.android.periodpals.MainCoroutineRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class UserLocationViewModelTest { + + @ExperimentalCoroutinesApi @get:Rule var mainCoroutineRule = MainCoroutineRule() + + private lateinit var userLocationModel: UserLocationModel + + private lateinit var userLocationViewModel: UserLocationViewModel + + companion object { + val uid = "user123" + val location = LocationGIS("Point", listOf(12.34, 56.78)) + val locationDto = UserLocationDto(uid, location) + } + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + userLocationModel = mock(UserLocationModel::class.java) + userLocationViewModel = UserLocationViewModel(userLocationModel) + } + + @After + fun tearDown() { + // Clean up resources if needed + } + + @Test + fun `uploadUserLocation successful create`() = runTest { + var onSuccessCalled = false + + `when`(userLocationModel.create(eq(locationDto), any(), any())).thenAnswer { + it.getArgument<() -> Unit>(1)() + } + + userLocationViewModel.uploadUserLocation(uid, location, onSuccess = { onSuccessCalled = true }) + + assertTrue(onSuccessCalled) + } + + @Test + fun `uploadUserLocation failure create calls update`() = runTest { + doAnswer { it.getArgument<(Exception) -> Unit>(2)(Exception("create failed")) } + .`when`(userLocationModel) + .create(eq(locationDto), any(), any()) + doAnswer { it.getArgument<() -> Unit>(1)() } + .`when`(userLocationModel) + .update(eq(locationDto), any(), any()) + + userLocationViewModel.uploadUserLocation(uid, location) + + verify(userLocationModel).update(eq(locationDto), any(), any()) + } + + @Test + fun `uploadUserLocation update failure`() = runTest { + var onFailureCalled = false + + doAnswer { it.getArgument<(Exception) -> Unit>(2)(Exception("create failed")) } + .`when`(userLocationModel) + .create(eq(locationDto), any(), any()) + doAnswer { it.getArgument<(Exception) -> Unit>(2)(Exception("update failed")) } + .`when`(userLocationModel) + .update(eq(locationDto), any(), any()) + + userLocationViewModel.uploadUserLocation( + "user123", + location, + onFailure = { onFailureCalled = true }, + ) + + assertTrue(onFailureCalled) + } + + @Test + fun `uploadUserLocation update success`() = runTest { + var onSuccessCalled = false + + doAnswer { it.getArgument<(Exception) -> Unit>(2)(Exception("create failed")) } + .`when`(userLocationModel) + .create(eq(locationDto), any(), any()) + doAnswer { it.getArgument<() -> Unit>(1)() } + .`when`(userLocationModel) + .update(eq(locationDto), any(), any()) + + userLocationViewModel.uploadUserLocation( + "user123", + location, + onSuccess = { onSuccessCalled = true }, + ) + + assertTrue(onSuccessCalled) + } +} diff --git a/app/src/test/java/com/android/periodpals/model/user/UserDtoTest.kt b/app/src/test/java/com/android/periodpals/model/user/UserDtoTest.kt index 3e6f7e6d1..f64e0528a 100644 --- a/app/src/test/java/com/android/periodpals/model/user/UserDtoTest.kt +++ b/app/src/test/java/com/android/periodpals/model/user/UserDtoTest.kt @@ -1,7 +1,5 @@ package com.android.periodpals.model.user -import com.android.periodpals.model.location.Location -import com.android.periodpals.model.location.parseLocationGIS import org.junit.Assert.assertEquals import org.junit.Test @@ -15,30 +13,11 @@ class UserDtoTest { val id = "test_id" val preferredDistance = 500 val fcmToken = "test_fcm_token" - val locationGIS = parseLocationGIS(Location.DEFAULT_LOCATION) } - val input = - UserDto( - name, - imageUrl, - description, - dob, - preferredDistance, - fcmToken, - locationGIS, - ) + val input = UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken) - val output = - User( - name, - imageUrl, - description, - dob, - preferredDistance, - fcmToken, - locationGIS, - ) + val output = User(name, imageUrl, description, dob, preferredDistance, fcmToken) @Test fun asUserIsCorrect() { diff --git a/app/src/test/java/com/android/periodpals/model/user/UserModelSupabaseTest.kt b/app/src/test/java/com/android/periodpals/model/user/UserModelSupabaseTest.kt index e0dce0249..049ff08cd 100644 --- a/app/src/test/java/com/android/periodpals/model/user/UserModelSupabaseTest.kt +++ b/app/src/test/java/com/android/periodpals/model/user/UserModelSupabaseTest.kt @@ -1,7 +1,5 @@ package com.android.periodpals.model.user -import com.android.periodpals.model.location.Location -import com.android.periodpals.model.location.parseLocationGIS import io.github.jan.supabase.createSupabaseClient import io.github.jan.supabase.postgrest.Postgrest import io.ktor.client.engine.mock.MockEngine @@ -32,11 +30,10 @@ class UserRepositorySupabaseTest { val id = "test_id" val preferredDistance = 500 val fcmToken = "test_fcm_token" - val locationGIS = parseLocationGIS(Location.DEFAULT_LOCATION) } private val defaultUserDto: UserDto = - UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken, locationGIS) + UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken) private val defaultUser: User = User(name, imageUrl, description, dob, preferredDistance, fcmToken) @@ -51,8 +48,7 @@ class UserRepositorySupabaseTest { "\"description\":\"${description}\"," + "\"dob\":\"${dob}\"," + "\"preferred_distance\":\"${preferredDistance}\"," + - "\"fcm_token\":\"${fcmToken}\"," + - "\"locationGIS\":{\"type\":\"Point\",\"coordinates\":[6.5665, 46.5186]}}" + + "\"fcm_token\":\"${fcmToken}\"}" + "]") } install(Postgrest) @@ -85,7 +81,10 @@ class UserRepositorySupabaseTest { runTest { val userRepositorySupabase = UserRepositorySupabase(supabaseClientSuccess) userRepositorySupabase.loadUserProfile( - id, { result = it }, { fail("should not call onFailure") }) + id, + { result = it }, + { fail("should not call onFailure") }, + ) assertEquals(defaultUserDto, result) } } diff --git a/app/src/test/java/com/android/periodpals/model/user/UserViewModelTest.kt b/app/src/test/java/com/android/periodpals/model/user/UserViewModelTest.kt index a61ce0f44..2ec85c0e8 100644 --- a/app/src/test/java/com/android/periodpals/model/user/UserViewModelTest.kt +++ b/app/src/test/java/com/android/periodpals/model/user/UserViewModelTest.kt @@ -70,16 +70,7 @@ class UserViewModelTest { @Test fun loadUserIsSuccessful() = runTest { - val user = - UserDto( - name, - imageUrl, - description, - dob, - preferredDistance, - fcmToken, - locationGIS, - ) + val user = UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken) val expected = user.asUser() doAnswer { it.getArgument<(UserDto) -> Unit>(1)(user) } @@ -104,16 +95,7 @@ class UserViewModelTest { @Test fun loadUsersIsSuccessful() = runTest { - val user = - UserDto( - name, - imageUrl, - description, - dob, - preferredDistance, - fcmToken, - locationGIS, - ) + val user = UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken) val expected = user.asUser() doAnswer { it.getArgument<(List) -> Unit>(0)(listOf(user)) } @@ -138,8 +120,7 @@ class UserViewModelTest { @Test fun saveUserIsSuccessful() = runTest { - val expected = - UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken, locationGIS).asUser() + val expected = UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken).asUser() doAnswer { it.getArgument<(UserDto) -> Unit>(1)(expected.asUserDto()) } .`when`(userModel) @@ -152,8 +133,7 @@ class UserViewModelTest { @Test fun saveUserHasFailed() = runTest { - val test = - UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken, locationGIS).asUser() + val test = UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken).asUser() doAnswer { it.getArgument<(Exception) -> Unit>(2)(Exception("failed")) } .`when`(userModel) diff --git a/app/src/test/java/com/android/periodpals/services/GPSServiceImplTest.kt b/app/src/test/java/com/android/periodpals/services/GPSServiceImplTest.kt index 219d98a93..ffd5b048e 100644 --- a/app/src/test/java/com/android/periodpals/services/GPSServiceImplTest.kt +++ b/app/src/test/java/com/android/periodpals/services/GPSServiceImplTest.kt @@ -10,10 +10,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.core.app.ActivityCompat import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.location.Location +import com.android.periodpals.model.location.UserLocationViewModel import com.android.periodpals.model.location.parseLocationGIS import com.android.periodpals.model.user.AuthenticationUserData -import com.android.periodpals.model.user.User -import com.android.periodpals.model.user.UserViewModel import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest @@ -58,7 +57,7 @@ class GPSServiceImplTest { @Mock private lateinit var mockPermissionLauncher: ActivityResultLauncher> private lateinit var authenticationViewModel: AuthenticationViewModel - private lateinit var userViewModel: UserViewModel + private lateinit var userLocationViewModel: UserLocationViewModel // Used to get the FusedLocationProviderClient private lateinit var mockLocationServices: MockedStatic @@ -86,7 +85,7 @@ class GPSServiceImplTest { mockActivity = mock(ComponentActivity::class.java) mockFusedLocationClient = mock(FusedLocationProviderClient::class.java) authenticationViewModel = mock(AuthenticationViewModel::class.java) - userViewModel = mock(UserViewModel::class.java) + userLocationViewModel = mock(UserLocationViewModel::class.java) mockLocationServices = mockStatic(LocationServices::class.java) @@ -127,7 +126,7 @@ class GPSServiceImplTest { ) // Create instance of GPSServiceImpl... - gpsService = GPSServiceImpl(mockActivity, authenticationViewModel, userViewModel) + gpsService = GPSServiceImpl(mockActivity, authenticationViewModel, userLocationViewModel) // ... and verify that registerForActivityResult was called verify(mockActivity) @@ -360,17 +359,16 @@ class GPSServiceImplTest { } @Test - fun `switchFromPreciseToApproximate should call saveUser with proper arguments`() { - val userNoLocation = - User("test name", "test url", "test description", "test dob", 1, "test fcm") + fun `switchFromPreciseToApproximate should call uploadUserLocation with proper arguments`() { val mockLat = 42.0 val mockLong = 16.0 - `when`(userViewModel.user).thenReturn(mutableStateOf(userNoLocation)) - `when`(userViewModel.loadUser(any(), any(), any())).doAnswer { - val onSuccess = it.arguments[1] as () -> Unit + `when`(authenticationViewModel.loadAuthenticationUserData(any(), any())).doAnswer { + val onSuccess = it.arguments[0] as () -> Unit onSuccess() } + `when`(authenticationViewModel.authUserData) + .thenReturn(mutableStateOf(AuthenticationUserData("test uid", "test email"))) // set the private _location value val locationField = GPSServiceImpl::class.java.getDeclaredField("_location") @@ -381,33 +379,28 @@ class GPSServiceImplTest { gpsService.askPermissionAndStartUpdates() gpsService.switchFromPreciseToApproximate() - val userExpected = - User( - "test name", - "test url", - "test description", - "test dob", - 1, - "test fcm", - parseLocationGIS(Location(mockLat, mockLong, "test location")), + verify(userLocationViewModel) + .uploadUserLocation( + eq("test uid"), + eq(parseLocationGIS(mutableStateFlow.value)), + any(), + any(), ) - verify(userViewModel).saveUser(eq(userExpected), any(), any()) } @Test - fun `cleanup should call saveUser with proper arguments`() { - val userNoLocation = - User("test name", "test url", "test description", "test dob", 1, "test fcm") + fun `cleanup should call uploadUserLocation with proper arguments`() { val mockLat = 42.0 val mockLong = 16.0 - val gpsService = GPSServiceImpl(mockActivity, authenticationViewModel, userViewModel) + val gpsService = GPSServiceImpl(mockActivity, authenticationViewModel, userLocationViewModel) - `when`(userViewModel.user).thenReturn(mutableStateOf(userNoLocation)) - `when`(userViewModel.loadUser(any(), any(), any())).doAnswer { - val onSuccess = it.arguments[1] as () -> Unit + `when`(authenticationViewModel.loadAuthenticationUserData(any(), any())).doAnswer { + val onSuccess = it.arguments[0] as () -> Unit onSuccess() } + `when`(authenticationViewModel.authUserData) + .thenReturn(mutableStateOf(AuthenticationUserData("test uid", "test email"))) // set the private _location value val locationField = GPSServiceImpl::class.java.getDeclaredField("_location") @@ -417,17 +410,13 @@ class GPSServiceImplTest { gpsService.cleanup() - val userExpected = - User( - "test name", - "test url", - "test description", - "test dob", - 1, - "test fcm", - parseLocationGIS(Location(mockLat, mockLong, "test location")), + verify(userLocationViewModel) + .uploadUserLocation( + eq("test uid"), + eq(parseLocationGIS(mutableStateFlow.value)), + any(), + any(), ) - verify(userViewModel).saveUser(eq(userExpected), any(), any()) } /** Mocks permissions granted for precise and approximate * */ diff --git a/app/src/test/java/com/android/periodpals/ui/settings/SettingsScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/settings/SettingsScreenTest.kt index 6ea767fc6..cd12906fd 100644 --- a/app/src/test/java/com/android/periodpals/ui/settings/SettingsScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/settings/SettingsScreenTest.kt @@ -11,8 +11,10 @@ import androidx.compose.ui.test.performScrollTo import com.android.periodpals.R import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.user.AuthenticationUserData +import com.android.periodpals.model.user.User import com.android.periodpals.model.user.UserViewModel import com.android.periodpals.resources.C.Tag.BottomNavigationMenu +import com.android.periodpals.resources.C.Tag.ProfileScreens.CreateProfileScreen import com.android.periodpals.resources.C.Tag.SettingsScreen import com.android.periodpals.resources.C.Tag.TopAppBar import com.android.periodpals.ui.navigation.NavigationActions @@ -41,6 +43,21 @@ class SettingsScreenTest { companion object { private val userData = mutableStateOf(AuthenticationUserData("uid", "email@epfl.com")) + + private val name = "John Doe" + private val imageUrl = "https://example.com" + private val description = "A short description" + private val dob = "01/01/2000" + private val preferredDistance = 500 + private val userState = + mutableStateOf( + User( + name = name, + imageUrl = imageUrl, + description = description, + dob = dob, + preferredDistance = preferredDistance, + )) } @Before @@ -50,6 +67,7 @@ class SettingsScreenTest { userViewModel = mock(UserViewModel::class.java) `when`(navigationActions.currentRoute()).thenReturn(Screen.SETTINGS) + `when`(userViewModel.user).thenReturn(userState) } @Test @@ -71,41 +89,25 @@ class SettingsScreenTest { composeTestRule.onNodeWithTag(BottomNavigationMenu.BOTTOM_NAVIGATION_MENU).assertDoesNotExist() composeTestRule - .onNodeWithTag(SettingsScreen.NOTIFICATIONS_CONTAINER) - .performScrollTo() - .assertIsDisplayed() - composeTestRule - .onNodeWithTag(SettingsScreen.THEME_CONTAINER) - .performScrollTo() - .assertIsDisplayed() - composeTestRule - .onNodeWithTag(SettingsScreen.ACCOUNT_MANAGEMENT_CONTAINER) - .performScrollTo() - .assertIsDisplayed() - composeTestRule - .onNodeWithTag(SettingsScreen.NOTIFICATIONS_DESCRIPTION) + .onNodeWithTag(SettingsScreen.REMARK_CONTAINER) .performScrollTo() .assertIsDisplayed() - composeTestRule.onNodeWithTag(SettingsScreen.PALS_TEXT).performScrollTo().assertIsDisplayed() - composeTestRule.onNodeWithTag(SettingsScreen.PALS_SWITCH).performScrollTo().assertIsDisplayed() composeTestRule - .onNodeWithTag(SettingsScreen.HORIZONTAL_DIVIDER) + .onNodeWithTag(SettingsScreen.REMARK_TEXT) .performScrollTo() .assertIsDisplayed() - composeTestRule.onNodeWithTag(SettingsScreen.PADS_TEXT).performScrollTo().assertIsDisplayed() - composeTestRule.onNodeWithTag(SettingsScreen.PADS_SWITCH).performScrollTo().assertIsDisplayed() - composeTestRule.onNodeWithTag(SettingsScreen.TAMPONS_TEXT).performScrollTo().assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.notifications_and_location_text)) composeTestRule - .onNodeWithTag(SettingsScreen.TAMPONS_SWITCH) + .onNodeWithTag(SettingsScreen.SLIDER_CONTAINER) .performScrollTo() .assertIsDisplayed() - composeTestRule.onNodeWithTag(SettingsScreen.ORGANIC_TEXT).performScrollTo().assertIsDisplayed() composeTestRule - .onNodeWithTag(SettingsScreen.ORGANIC_SWITCH) + .onNodeWithTag(CreateProfileScreen.FILTER_RADIUS_EXPLANATION_TEXT) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.create_profile_radius_explanation_text)) composeTestRule - .onNodeWithTag(SettingsScreen.THEME_DROP_DOWN_MENU_BOX) + .onNodeWithTag(SettingsScreen.ACCOUNT_MANAGEMENT_CONTAINER) .performScrollTo() .assertIsDisplayed() composeTestRule @@ -170,20 +172,6 @@ class SettingsScreenTest { verify(navigationActions).goBack() } - @Test - fun performClickOnDropDownMenu() { - composeTestRule.setContent { - SettingsScreen(userViewModel, authenticationViewModel, navigationActions) - } - - composeTestRule - .onNodeWithTag(SettingsScreen.THEME_DROP_DOWN_MENU_BOX) - .performScrollTo() - .performClick() - - composeTestRule.onNodeWithTag(SettingsScreen.THEME_DROP_DOWN_MENU).performClick() - } - @Test fun notDeleteAccountButtonDismissDialog() { composeTestRule.setContent { @@ -239,6 +227,10 @@ class SettingsScreenTest { val onSuccess = it.arguments[0] as () -> Unit onSuccess() } + `when`(authenticationViewModel.logOut(any(), any())).thenAnswer { + val onSuccess = it.arguments[0] as () -> Unit + onSuccess() + } `when`(userViewModel.deleteUser(any(), any(), any())).thenAnswer { val onFailure = it.arguments[2] as (Exception) -> Unit onFailure(Exception("Error deleting user account")) @@ -267,6 +259,10 @@ class SettingsScreenTest { val onSuccess = it.arguments[0] as () -> Unit onSuccess() } + `when`(authenticationViewModel.logOut(any(), any())).thenAnswer { + val onSuccess = it.arguments[0] as () -> Unit + onSuccess() + } `when`(userViewModel.deleteUser(any(), any(), any())).thenAnswer { val onSuccess = it.arguments[1] as () -> Unit onSuccess() @@ -286,4 +282,66 @@ class SettingsScreenTest { verify(navigationActions).navigateTo(Screen.SIGN_IN) } + + @Test + fun deleteAccountVMLogOutFailure() { + `when`(authenticationViewModel.authUserData).thenReturn(userData) + `when`(authenticationViewModel.loadAuthenticationUserData(any(), any())).thenAnswer { + val onSuccess = it.arguments[0] as () -> Unit + onSuccess() + } + `when`(authenticationViewModel.logOut(any(), any())).thenAnswer { + val onFailure = it.arguments[1] as (Exception) -> Unit + onFailure(Exception("Error logging out user")) + } + + composeTestRule.setContent { + SettingsScreen(userViewModel, authenticationViewModel, navigationActions) + } + + composeTestRule + .onNodeWithTag(SettingsScreen.DELETE_ACCOUNT_ICON) + .performScrollTo() + .performClick() + composeTestRule.onNodeWithTag(SettingsScreen.DELETE_BUTTON).performClick() + + verify(userViewModel, never()).deleteUser(eq(userData.value.uid), any(), any()) + + verify(navigationActions, never()).navigateTo(any()) + verify(navigationActions, never()).navigateTo(any()) + } + + @Test + fun deleteAccountVMLoadDataFailure() { + `when`(authenticationViewModel.authUserData).thenReturn(userData) + `when`(authenticationViewModel.loadAuthenticationUserData(any(), any())).thenAnswer { + val onFailure = it.arguments[1] as (Exception) -> Unit + onFailure(Exception("Error loading user data")) + } + + composeTestRule.setContent { + SettingsScreen(userViewModel, authenticationViewModel, navigationActions) + } + + composeTestRule + .onNodeWithTag(SettingsScreen.DELETE_ACCOUNT_ICON) + .performScrollTo() + .performClick() + composeTestRule.onNodeWithTag(SettingsScreen.DELETE_BUTTON).performClick() + + verify(userViewModel, never()).deleteUser(eq(userData.value.uid), any(), any()) + + verify(navigationActions, never()).navigateTo(any()) + verify(navigationActions, never()).navigateTo(any()) + } + + @Test + fun sliderLogicTest() { + `when`(userViewModel.saveUser(any(), any(), any())).thenAnswer { + val onSuccess = it.arguments[1] as () -> Unit + onSuccess() + } + + sliderLogic(preferredDistance.toFloat(), userViewModel) + } } diff --git a/supabase/functions/push_new_alert/index.ts b/supabase/functions/push_new_alert/index.ts index 2caa6f14b..9ab72dcd9 100644 --- a/supabase/functions/push_new_alert/index.ts +++ b/supabase/functions/push_new_alert/index.ts @@ -52,6 +52,7 @@ Deno.serve(async (req) => { console.error("Error getting fcm tokens of valid users:", error); return new Response('Error getting fcm tokens of valid users', { status: 500 }); } + console.log(`Number of fcm tokens: ${fcmTokens.length}`); // get access token for sending notifications const accessToken = await getAccessToken({ @@ -88,6 +89,7 @@ Deno.serve(async (req) => { console.error('No notifications were sent'); return new Response('No notifications were sent', { status: 500 }) } + console.log('Notifications sent'); return new Response('Notifications sent', { status: 200 }); });