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 });
});