diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f513af324..955dc4316 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -192,6 +192,8 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.robolectric) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4.v105) globalTestImplementation(libs.androidx.junit) globalTestImplementation(libs.androidx.espresso.core) @@ -253,6 +255,16 @@ dependencies { // Live Data implementation(libs.androidx.runtime.livedata) + + // StreamChatSDK + implementation(libs.stream.chat.android.offline.v660) + implementation(libs.stream.chat.android.compose.v660) + implementation(libs.stream.chat.android.ui.components.v660) + + // java-jwt library + implementation(libs.java.jwt) + + } secrets { 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 b18200165..65c3d5c0d 100644 --- a/app/src/androidTest/java/com/android/periodpals/services/GPSServiceImplInstrumentedTest.kt +++ b/app/src/androidTest/java/com/android/periodpals/services/GPSServiceImplInstrumentedTest.kt @@ -2,11 +2,14 @@ package com.android.periodpals.services import android.Manifest import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario 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.user.AuthenticationUserData import com.android.periodpals.model.user.UserViewModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -17,6 +20,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` @RunWith(AndroidJUnit4::class) class GPSServiceImplInstrumentedTest { @@ -31,6 +35,7 @@ class GPSServiceImplInstrumentedTest { private lateinit var scenario: ActivityScenario private lateinit var activity: ComponentActivity private lateinit var gpsService: GPSServiceImpl + private lateinit var authenticationViewModel: AuthenticationViewModel private lateinit var userViewModel: UserViewModel // Default location @@ -39,6 +44,7 @@ class GPSServiceImplInstrumentedTest { @Before fun setup() { + authenticationViewModel = mock(AuthenticationViewModel::class.java) userViewModel = mock(UserViewModel::class.java) scenario = ActivityScenario.launch(ComponentActivity::class.java) @@ -49,15 +55,20 @@ class GPSServiceImplInstrumentedTest { scenario.onActivity { activity -> this.activity = activity - gpsService = GPSServiceImpl(this.activity, userViewModel) + gpsService = GPSServiceImpl(this.activity, authenticationViewModel, userViewModel) } // Once the GPSService has been initialized, set its state to resumed scenario.moveToState(Lifecycle.State.RESUMED) + + `when`(authenticationViewModel.authUserData) + .thenReturn(mutableStateOf(AuthenticationUserData("test", "test"))) } @After fun tearDownService() { + `when`(authenticationViewModel.authUserData) + .thenReturn(mutableStateOf(AuthenticationUserData("test", "test"))) gpsService.cleanup() } diff --git a/app/src/main/java/com/android/periodpals/MainActivity.kt b/app/src/main/java/com/android/periodpals/MainActivity.kt index f4559f668..d989de5a3 100644 --- a/app/src/main/java/com/android/periodpals/MainActivity.kt +++ b/app/src/main/java/com/android/periodpals/MainActivity.kt @@ -9,7 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -35,7 +38,6 @@ import com.android.periodpals.ui.alert.CreateAlertScreen import com.android.periodpals.ui.alert.EditAlertScreen import com.android.periodpals.ui.authentication.SignInScreen import com.android.periodpals.ui.authentication.SignUpScreen -import com.android.periodpals.ui.chat.ChatScreen import com.android.periodpals.ui.map.MapScreen import com.android.periodpals.ui.navigation.NavigationActions import com.android.periodpals.ui.navigation.Route @@ -47,14 +49,24 @@ import com.android.periodpals.ui.settings.SettingsScreen import com.android.periodpals.ui.theme.PeriodPalsAppTheme import com.android.periodpals.ui.timer.TimerScreen import com.google.android.gms.common.GoogleApiAvailability +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.logger.ChatLogLevel +import io.getstream.chat.android.compose.ui.channels.ChannelsScreen +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory +import io.getstream.chat.android.state.plugin.config.StatePluginConfig +import io.getstream.chat.android.state.plugin.factory.StreamStatePluginFactory import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.createSupabaseClient import io.github.jan.supabase.postgrest.Postgrest import io.github.jan.supabase.storage.Storage import org.osmdroid.config.Configuration -class MainActivity : ComponentActivity() { +private const val TAG = "MainActivity" +private const val CHANNEL_SCREEN_TITLE = "Your Chats" +class MainActivity : ComponentActivity() { private lateinit var gpsService: GPSServiceImpl private lateinit var pushNotificationsService: PushNotificationsServiceImpl private lateinit var chatViewModel: ChatViewModel @@ -80,13 +92,15 @@ class MainActivity : ComponentActivity() { private val alertViewModel = AlertViewModel(alertModel) private val timerModel = TimerRepositorySupabase(supabaseClient) + private lateinit var timerViewModel: TimerViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - gpsService = GPSServiceImpl(this, userViewModel) - pushNotificationsService = PushNotificationsServiceImpl(this, userViewModel) + gpsService = GPSServiceImpl(this, authenticationViewModel, userViewModel) + pushNotificationsService = + PushNotificationsServiceImpl(this, authenticationViewModel, userViewModel) timerManager = TimerManager(this) timerViewModel = TimerViewModel(timerModel, timerManager) @@ -96,7 +110,22 @@ class MainActivity : ComponentActivity() { // Check if Google Play Services are available GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(this) - chatViewModel = ChatViewModel() + // Set up the OfflinePlugin for offline storage + val offlinePluginFactory = + StreamOfflinePluginFactory( + appContext = applicationContext, + ) + val statePluginFactory = + StreamStatePluginFactory(config = StatePluginConfig(), appContext = this) + + // Set up the chat client for API calls and with the plugin for offline storage + val chatClient = + ChatClient.Builder(BuildConfig.STREAM_SDK_KEY, applicationContext) + .withPlugins(offlinePluginFactory, statePluginFactory) + .logLevel(ChatLogLevel.ALL) // Set to NOTHING in prod + .build() + + chatViewModel = ChatViewModel(chatClient) setContent { PeriodPalsAppTheme { @@ -109,6 +138,7 @@ class MainActivity : ComponentActivity() { userViewModel, alertViewModel, timerViewModel, + chatClient, chatViewModel, ) } @@ -159,7 +189,8 @@ fun PeriodPalsApp( userViewModel: UserViewModel, alertViewModel: AlertViewModel, timerViewModel: TimerViewModel, - chatViewModel: ChatViewModel, + chatClient: ChatClient, + chatViewModel: ChatViewModel ) { val navController = rememberNavController() val navigationActions = NavigationActions(navController) @@ -203,7 +234,37 @@ fun PeriodPalsApp( composable(Screen.EDIT_ALERT) { EditAlertScreen(locationViewModel, gpsService, alertViewModel, navigationActions) } - composable(Screen.CHAT) { ChatScreen(chatViewModel, navigationActions) } + + composable(Screen.CHAT) { + val clientInitialisationState by chatClient.clientState.initializationState.collectAsState() + val clientConnectionState by chatClient.clientState.connectionState.collectAsState() + val context = LocalContext.current + + Log.d(TAG, "Client initialization state: $clientInitialisationState") + + ChatTheme { + when (clientInitialisationState) { + InitializationState.COMPLETE -> { + Log.d(TAG, "Client initialization completed") + Log.d(TAG, "Client connection state $clientConnectionState") + ChannelsScreen( + title = CHANNEL_SCREEN_TITLE, + isShowingHeader = true, + onChannelClick = { + /** TODO: implement channels here */ + }, + onBackPressed = { navigationActions.navigateTo(Screen.ALERT_LIST) }, + ) + } + InitializationState.INITIALIZING -> { + Log.d(TAG, "Client initializing") + } + InitializationState.NOT_INITIALIZED -> { + Log.d(TAG, "Client not initialized yet.") + } + } + } + } } // Map @@ -228,7 +289,13 @@ fun PeriodPalsApp( // Profile navigation(startDestination = Screen.PROFILE, route = Route.PROFILE) { composable(Screen.PROFILE) { - ProfileScreen(userViewModel, pushNotificationsService, navigationActions) + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions, + ) } composable(Screen.EDIT_PROFILE) { EditProfileScreen(userViewModel, navigationActions) } composable(Screen.SETTINGS) { diff --git a/app/src/main/java/com/android/periodpals/model/chat/ChatViewModel.kt b/app/src/main/java/com/android/periodpals/model/chat/ChatViewModel.kt index 4a7f1dd20..0a0145686 100644 --- a/app/src/main/java/com/android/periodpals/model/chat/ChatViewModel.kt +++ b/app/src/main/java/com/android/periodpals/model/chat/ChatViewModel.kt @@ -1,4 +1,59 @@ package com.android.periodpals.model.chat -/** ViewModel for managing chat-related data and operations. */ -class ChatViewModel {} +import android.util.Log +import androidx.lifecycle.ViewModel +import com.android.periodpals.model.authentication.AuthenticationViewModel +import com.android.periodpals.model.user.User +import com.android.periodpals.services.JwtTokenService +import io.getstream.chat.android.client.ChatClient + +private const val TAG = "ChatViewModel" + +/** + * View model for the chat feature. + * + * @property chatClient The client used for connecting to the Stream Chat service. + */ +class ChatViewModel(private val chatClient: ChatClient) : ViewModel() { + + /** + * Connects the user to the Stream Chat service. + * + * @param profile The user's profile information. + * @param authenticationViewModel The ViewModel used for authentication. + * @param onSuccess Callback function to be called on successful connection. + * @param onFailure Callback function to be called on connection failure, with the exception as a + * parameter. + */ + fun connectUser( + profile: User?, + authenticationViewModel: AuthenticationViewModel, + onSuccess: () -> Unit = { Log.d(TAG, "User connected successfully.") }, + onFailure: (Exception) -> Unit = { Log.d(TAG, "Failed to connect user: ${it.message}") }, + ) { + if (profile == null || authenticationViewModel.authUserData.value == null) { + Log.d(TAG, "Failed to connect user: profile or authentication data is null.") + onFailure(RuntimeException("Profile or authentication data is null.")) + return + } + + val uid = authenticationViewModel.authUserData.value!!.uid + JwtTokenService.generateStreamToken( + uid = uid, + onSuccess = { + val token = it + val userImage = profile.imageUrl.ifEmpty { "https://bit.ly/2TIt8NR" } + val user = + io.getstream.chat.android.models.User( + id = uid, name = profile.name, image = userImage) + + chatClient.connectUser(user = user, token = token).enqueue() + Log.d(TAG, "User connected successfully.") + onSuccess() + }, + onFailure = { + Log.d(TAG, "Failed to generate token.") + onFailure(it) + }) + } +} diff --git a/app/src/main/java/com/android/periodpals/model/user/UserModel.kt b/app/src/main/java/com/android/periodpals/model/user/UserModel.kt index c240dd757..725717b5c 100644 --- a/app/src/main/java/com/android/periodpals/model/user/UserModel.kt +++ b/app/src/main/java/com/android/periodpals/model/user/UserModel.kt @@ -5,11 +5,25 @@ interface UserRepository { /** * Loads the user profile for the given user ID. * + * @param idUser The ID of the user profile to be loaded. * @param onSuccess callback to be called on successful call on this function returning the * UserDto * @param onFailure callback to be called when error is caught */ - suspend fun loadUserProfile(onSuccess: (UserDto) -> Unit, onFailure: (Exception) -> Unit) + suspend fun loadUserProfile( + idUser: String, + onSuccess: (UserDto) -> Unit, + onFailure: (Exception) -> Unit + ) + + /** + * Loads all user profiles. + * + * @param onSuccess callback to be called on successful call on this function returning the list + * of UserDto + * @param onFailure callback to be called when error is caught + */ + suspend fun loadUserProfiles(onSuccess: (List) -> Unit, onFailure: (Exception) -> Unit) /** * Creates the user profile. @@ -25,7 +39,8 @@ interface UserRepository { * else create new. * * @param userDto The user profile to be checked - * @param onSuccess callback block + * @param onSuccess callback block to be called on success + * @param onFailure callback block to be called when exception is caught */ suspend fun upsertUserProfile( userDto: UserDto, 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 0e17f084d..394e882b5 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 @@ -7,17 +7,18 @@ import io.github.jan.supabase.storage.storage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +private const val TAG = "UserRepositorySupabase" +private const val USERS = "users" + /** * Implementation of UserRepository using Supabase. * * @property supabase The Supabase client used for making API calls. */ -private const val TAG = "UserRepositorySupabase" -private const val USERS = "users" - class UserRepositorySupabase(private val supabase: SupabaseClient) : UserRepository { override suspend fun loadUserProfile( + idUser: String, onSuccess: (UserDto) -> Unit, onFailure: (Exception) -> Unit, ) { @@ -25,7 +26,7 @@ class UserRepositorySupabase(private val supabase: SupabaseClient) : UserReposit val result = withContext(Dispatchers.Main) { supabase.postgrest[USERS] - .select {} + .select { filter { eq("user_id", idUser) } } .decodeSingle() // RLS rules only allows user to check their own line } Log.d(TAG, "loadUserProfile: Success") @@ -36,6 +37,23 @@ class UserRepositorySupabase(private val supabase: SupabaseClient) : UserReposit } } + override suspend fun loadUserProfiles( + onSuccess: (List) -> Unit, + onFailure: (Exception) -> Unit, + ) { + try { + val result = + withContext(Dispatchers.Main) { + supabase.postgrest[USERS].select {}.decodeList() + } + Log.d(TAG, "loadUserProfiles: Success") + onSuccess(result) + } catch (e: Exception) { + Log.d(TAG, "loadUserProfiles: fail to load user profile: ${e.message}") + onFailure(e) + } + } + override suspend fun createUserProfile( user: User, onSuccess: () -> Unit, @@ -122,7 +140,7 @@ class UserRepositorySupabase(private val supabase: SupabaseClient) : UserReposit ) { try { withContext(Dispatchers.Main) { - val file = supabase.storage.from("avatars").downloadAuthenticated("$filePath.jpg") + val file = supabase.storage.from("avatars").downloadPublic("$filePath.jpg") Log.d(TAG, "downloadFile: Success") onSuccess(file) } diff --git a/app/src/main/java/com/android/periodpals/model/user/UserViewModel.kt b/app/src/main/java/com/android/periodpals/model/user/UserViewModel.kt index 7c148414b..b6096d9e3 100644 --- a/app/src/main/java/com/android/periodpals/model/user/UserViewModel.kt +++ b/app/src/main/java/com/android/periodpals/model/user/UserViewModel.kt @@ -9,6 +9,8 @@ import com.dsc.form_builder.FormState import com.dsc.form_builder.TextFieldState import com.dsc.form_builder.Validators import java.text.DateFormat +import java.time.LocalDate +import java.time.format.DateTimeFormatter import java.util.Locale import kotlinx.coroutines.launch @@ -16,13 +18,15 @@ private const val TAG = "UserViewModel" private const val MAX_NAME_LENGTH = 128 private const val MAX_DESCRIPTION_LENGTH = 512 +const val MIN_AGE = 16L private const val ERROR_INVALID_NAME = "Please enter a name" private const val ERROR_NAME_TOO_LONG = "Name must be less than $MAX_NAME_LENGTH characters" private const val ERROR_INVALID_DESCRIPTION = "Please enter a description" private const val ERROR_DESCRIPTION_TOO_LONG = "Description must be less than $MAX_DESCRIPTION_LENGTH characters" -private const val ERROR_INVALID_DOB = "Invalid date" +private const val ERROR_INVALID_DOB = "Please enter a valid date" +private const val ERROR_TOO_YOUNG = "You must be at least $MIN_AGE years old" private val nameValidators = listOf( @@ -38,6 +42,7 @@ private val dobValidators = listOf( Validators.Required(message = ERROR_INVALID_DOB), Validators.Custom(message = ERROR_INVALID_DOB, function = { validateDate(it as String) }), + Validators.Custom(message = ERROR_TOO_YOUNG, function = { isOldEnough(it as String) }), ) private val profileImageValidators = emptyList() // TODO: add validators when profile image is implemented @@ -57,6 +62,8 @@ class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewMo private val _user = mutableStateOf(null) val user: State = _user + private val _users = mutableStateOf?>(null) + val users: State?> = _users private val _avatar = mutableStateOf(null) val avatar: State = _avatar @@ -71,39 +78,16 @@ class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewMo name = PROFILE_IMAGE_STATE_NAME, validators = profileImageValidators), )) - /** - * Initializes the user profile. - * - * @param onSuccess Callback function to be called when the user profile is successfully loaded. - * @param onFailure Callback function to be called when there is an error loading the user - * profile. - */ - fun init( - onSuccess: () -> Unit = { Log.d(TAG, "init success callback") }, - onFailure: (Exception) -> Unit = { e: Exception -> - Log.d(TAG, "init failure callback: ${e.message}") - }, - ) { - loadUser( - onSuccess = { - user.value?.let { - downloadFile( - it.imageUrl, - onSuccess = { onSuccess() }, - onFailure = { e: Exception -> onFailure(Exception(e)) }) - } - }, - onFailure = { e: Exception -> onFailure(Exception(e)) }) - } - /** * Loads the user profile and updates the user state. * + * @param idUser The ID of the user profile to be loaded. * @param onSuccess Callback function to be called when the user profile is successfully loaded. * @param onFailure Callback function to be called when there is an error loading the user * profile. */ fun loadUser( + idUser: String, onSuccess: () -> Unit = { Log.d(TAG, "loadUser success callback") }, onFailure: (Exception) -> Unit = { e: Exception -> Log.d(TAG, "loadUser failure callback: ${e.message}") @@ -111,6 +95,7 @@ class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewMo ) { viewModelScope.launch { userRepository.loadUserProfile( + idUser, onSuccess = { userDto -> Log.d(TAG, "loadUserProfile: Successful") _user.value = userDto.asUser() @@ -125,6 +110,35 @@ class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewMo } } + /** + * Loads all user profiles and updates the user state. + * + * @param onSuccess Callback function to be called when the user profiles are successfully loaded. + * @param onFailure Callback function to be called when there is an error loading the user + * profiles. + */ + fun loadUsers( + onSuccess: () -> Unit = { Log.d(TAG, "loadUsers success callback") }, + onFailure: (Exception) -> Unit = { e: Exception -> + Log.d(TAG, "loadUsers failure callback: ${e.message}") + }, + ) { + viewModelScope.launch { + userRepository.loadUserProfiles( + onSuccess = { userDtos -> + Log.d(TAG, "loadUsers: Successful") + _users.value = userDtos.map { it.asUser() } + onSuccess() + }, + onFailure = { e: Exception -> + Log.d(TAG, "loadUsers: fail to load user profiles: ${e.message}") + _users.value = null + onFailure(e) + }, + ) + } + } + /** * Saves the user profile. * @@ -201,7 +215,7 @@ class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewMo onSuccess: () -> Unit = { Log.d(TAG, "uploadFile success callback") }, onFailure: (Exception) -> Unit = { e: Exception -> Log.d(TAG, "uploadFile failure callback: ${e.message}") - } + }, ) { viewModelScope.launch { userRepository.uploadFile( @@ -214,7 +228,8 @@ class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewMo onFailure = { e: Exception -> Log.d(TAG, "uploadFile: fail to upload file: ${e.message}") onFailure(e) - }) + }, + ) } } @@ -230,7 +245,7 @@ class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewMo onSuccess: () -> Unit = { Log.d(TAG, "downloadFile success callback") }, onFailure: (Exception) -> Unit = { e: Exception -> Log.d(TAG, "downloadFile failure callback: ${e.message}") - } + }, ) { viewModelScope.launch { userRepository.downloadFile( @@ -243,7 +258,8 @@ class UserViewModel(private val userRepository: UserRepositorySupabase) : ViewMo onFailure = { e: Exception -> Log.d(TAG, "downloadFile: fail to download file: ${e.message}") onFailure(e) - }) + }, + ) } } } @@ -264,3 +280,18 @@ fun validateDate(date: String): Boolean { false } } + +/** + * Validates the user is at least 16 years old. + * + * @param date The date string to validate. + * @return True if the user is at least 16 years old, otherwise false. + */ +fun isOldEnough(date: String): Boolean { + return try { + LocalDate.parse(date, DateTimeFormatter.ofPattern("dd/MM/yyyy")) + .isBefore(LocalDate.now().minusYears(MIN_AGE)) + } catch (e: Exception) { + false + } +} 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 5a8bb1d4e..0cb048f60 100644 --- a/app/src/main/java/com/android/periodpals/resources/C.kt +++ b/app/src/main/java/com/android/periodpals/resources/C.kt @@ -177,6 +177,7 @@ object C { const val YOUR_PROFILE_SECTION = "yourProfileSection" const val NAME_INPUT_FIELD = "nameInputField" const val DOB_INPUT_FIELD = "dobInputField" + const val DOB_MIN_AGE_TEXT = "dobMinAgeText" const val DESCRIPTION_INPUT_FIELD = "descriptionInputField" const val SAVE_BUTTON = "saveButton" } @@ -223,12 +224,6 @@ object C { const val STOP_BUTTON = "Stop button" const val USEFUL_TIP = "usefulTip" const val USEFUL_TIP_TEXT = "usefulTipText" - - // Displayed texts - const val DISPLAYED_TEXT_ONE = - "Start your tampon timer.\n" + "You’ll be reminded to change it !" - const val DISPLAYED_TEXT_TWO = - "You’ve got this. Stay strong !\n" + "Don’t forget to stay hydrated !" } } } diff --git a/app/src/main/java/com/android/periodpals/resources/Dimens.kt b/app/src/main/java/com/android/periodpals/resources/Dimens.kt index a6a321660..d8bdca52a 100644 --- a/app/src/main/java/com/android/periodpals/resources/Dimens.kt +++ b/app/src/main/java/com/android/periodpals/resources/Dimens.kt @@ -34,10 +34,10 @@ data class Dimens( val roundedPercent: Int = 50, ) -// Width <= 360dp +// Width <= 300dp val CompactSmallDimens = Dimens(small1 = 3.dp, iconSize = 20.dp) -// 360dp < Width <= 500dp +// 300dp < Width <= 500dp /** Reference padding and spacing values for a medium screen size. */ val CompactMediumDimens = Dimens( 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 682e416ae..97fa91b58 100644 --- a/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt +++ b/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt @@ -9,6 +9,7 @@ import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher 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.parseLocationGIS import com.android.periodpals.model.user.UserViewModel @@ -49,6 +50,7 @@ private enum class REQUEST_TYPE { */ class GPSServiceImpl( private val activity: ComponentActivity, + private val authenticationViewModel: AuthenticationViewModel, private val userViewModel: UserViewModel, ) : GPSService { private var _location = MutableStateFlow(Location.DEFAULT_LOCATION) @@ -196,7 +198,10 @@ 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)) diff --git a/app/src/main/java/com/android/periodpals/services/JwtTokenService.kt b/app/src/main/java/com/android/periodpals/services/JwtTokenService.kt new file mode 100644 index 000000000..d22e44390 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/services/JwtTokenService.kt @@ -0,0 +1,41 @@ +package com.android.periodpals.services + +import android.util.Log +import com.android.periodpals.BuildConfig +import com.android.periodpals.model.timer.HOUR +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import java.util.Date + +private const val TAG = "JwtTokenService" + +/** Service for generating JWT tokens. */ +class JwtTokenService { + + companion object { + /** + * Generates a Stream Chat token for the given user ID. + * + * @param uid The user ID. + * @param onSuccess The callback to be invoked when the token is generated successfully. + * @param onFailure The callback to be invoked when the token generation fails. + */ + fun generateStreamToken( + uid: String?, + onSuccess: (String) -> Unit, + onFailure: (Exception) -> Unit + ) { + if (uid.isNullOrBlank()) { + Log.d(TAG, "User ID is null or blank.") + onFailure(Exception("User ID is null or blank.")) + return + } + val algorithm = Algorithm.HMAC256(BuildConfig.STREAM_SDK_SECRET) + + val expirationTime = Date(System.currentTimeMillis() + HOUR) + val token = + JWT.create().withClaim("user_id", uid).withExpiresAt(expirationTime).sign(algorithm) + onSuccess(token) + } + } +} diff --git a/app/src/main/java/com/android/periodpals/services/PushNotificationsServiceImpl.kt b/app/src/main/java/com/android/periodpals/services/PushNotificationsServiceImpl.kt index d5db36379..7a89eb685 100644 --- a/app/src/main/java/com/android/periodpals/services/PushNotificationsServiceImpl.kt +++ b/app/src/main/java/com/android/periodpals/services/PushNotificationsServiceImpl.kt @@ -16,6 +16,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.android.periodpals.R +import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.user.UserViewModel import com.google.firebase.FirebaseApp import com.google.firebase.messaging.FirebaseMessaging @@ -37,6 +38,7 @@ private const val TIMEOUT = 1000L */ class PushNotificationsServiceImpl( private val activity: ComponentActivity, + private val authenticationViewModel: AuthenticationViewModel?, private val userViewModel: UserViewModel?, ) : FirebaseMessagingService(), PushNotificationsService { @@ -50,7 +52,7 @@ class PushNotificationsServiceImpl( handlePermissionResult(it) } - constructor() : this(ComponentActivity(), null) { + constructor() : this(ComponentActivity(), null, null) { Log.e(TAG, "went through empty constructor") } @@ -229,7 +231,10 @@ class PushNotificationsServiceImpl( Log.e(TAG, "UserViewModel not available") return } + authenticationViewModel?.loadAuthenticationUserData( + onFailure = { Log.d(TAG, "Authentication data is null") }) userViewModel.loadUser( + authenticationViewModel?.authUserData?.value!!.uid, onSuccess = { Log.d(TAG, "Uploading token to server") val newUser = userViewModel.user.value?.copy(fcmToken = token) diff --git a/app/src/main/java/com/android/periodpals/ui/alert/AlertLists.kt b/app/src/main/java/com/android/periodpals/ui/alert/AlertLists.kt index b1aeb6d66..79f44f536 100644 --- a/app/src/main/java/com/android/periodpals/ui/alert/AlertLists.kt +++ b/app/src/main/java/com/android/periodpals/ui/alert/AlertLists.kt @@ -1,5 +1,6 @@ package com.android.periodpals.ui.alert +import android.content.Context import android.os.Handler import android.os.Looper import android.util.Log @@ -49,6 +50,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import com.android.periodpals.R import com.android.periodpals.model.alert.Alert import com.android.periodpals.model.alert.AlertViewModel import com.android.periodpals.model.alert.Product @@ -79,17 +81,10 @@ import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens private val SELECTED_TAB_DEFAULT = AlertListsTab.MY_ALERTS -private const val SCREEN_TITLE = "Alert Lists" -private const val MY_ALERTS_TAB_TITLE = "My Alerts" -private const val PALS_ALERTS_TAB_TITLE = "Pals Alerts" -private const val NO_MY_ALERTS_DIALOG = "You haven't asked for help yet !" -private const val NO_PAL_ALERTS_DIALOG = "No pal needs help yet !" -private const val MY_ALERT_EDIT_TEXT = "Edit" -private const val PAL_ALERT_ACCEPT_TEXT = "Accept" -private const val PAL_ALERT_DECLINE_TEXT = "Decline" + private const val TAG = "AlertListsScreen" + private const val DEFAULT_RADIUS = 100.0 -private const val URGENCY_FILTER_DEFAULT_VALUE = "No Preference" /** Enum class representing the tabs in the AlertLists screen. */ private enum class AlertListsTab { @@ -154,7 +149,7 @@ fun AlertListsScreen( topBar = { Column(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { TopAppBar( - title = SCREEN_TITLE, + title = context.getString(R.string.alert_lists_screen_title), chatButton = true, onChatButtonClick = { navigationActions.navigateTo(Screen.CHAT) }) TabRow( @@ -169,7 +164,7 @@ fun AlertListsScreen( text = { Text( modifier = Modifier.wrapContentSize(), - text = MY_ALERTS_TAB_TITLE, + text = context.getString(R.string.alert_lists_tab_my_alerts_title), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineSmall, ) @@ -182,7 +177,7 @@ fun AlertListsScreen( text = { Text( modifier = Modifier.wrapContentSize(), - text = PALS_ALERTS_TAB_TITLE, + text = context.getString(R.string.alert_lists_tab_pals_alerts_title), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineSmall, ) @@ -215,7 +210,7 @@ fun AlertListsScreen( location = selectedLocation, product = productToPeriodPalsIcon(productFilter!!).textId, urgency = - if (urgencyFilter == null) URGENCY_FILTER_DEFAULT_VALUE + if (urgencyFilter == null) context.getString(R.string.alert_lists_filter_default) else urgencyToPeriodPalsIcon(urgencyFilter!!).textId, onDismiss = { showFilterDialog = false }, onLocationSelected = { selectedLocation = it }, @@ -269,13 +264,15 @@ fun AlertListsScreen( when (selectedTab) { AlertListsTab.MY_ALERTS -> if (myAlertsList.isEmpty()) { - item { NoAlertDialog(NO_MY_ALERTS_DIALOG) } + item { NoAlertDialog(context.getString(R.string.alert_lists_no_my_alerts_dialog)) } } else { - items(myAlertsList) { alert -> MyAlertItem(alert, alertViewModel, navigationActions) } + items(myAlertsList) { alert -> + MyAlertItem(alert, alertViewModel, navigationActions, context) + } } AlertListsTab.PALS_ALERTS -> if (palsAlertsList.value.isEmpty()) { - item { NoAlertDialog(NO_PAL_ALERTS_DIALOG) } + item { NoAlertDialog(context.getString(R.string.alert_lists_no_pals_alerts_dialog)) } } else { items(palsAlertsList.value) { alert -> PalsAlertItem(alert = alert) } } @@ -291,12 +288,14 @@ fun AlertListsScreen( * @param alert The alert to be displayed. * @param alertViewModel The view model for managing alert data. * @param navigationActions The navigation actions for handling navigation events. + * @param context The context of the current activity. */ @Composable private fun MyAlertItem( alert: Alert, alertViewModel: AlertViewModel, - navigationActions: NavigationActions + navigationActions: NavigationActions, + context: Context, ) { val idTestTag = alert.id Card( @@ -356,7 +355,7 @@ private fun MyAlertItem( ) // Edit alert text Text( - text = MY_ALERT_EDIT_TEXT, + text = context.getString(R.string.alert_lists_my_alert_edit_text), style = MaterialTheme.typography.labelMedium, modifier = Modifier.wrapContentSize(), ) @@ -548,7 +547,7 @@ private fun AlertAcceptButtons(idTestTag: String) { ) { // Accept alert button AlertActionButton( - text = PAL_ALERT_ACCEPT_TEXT, + text = context.getString(R.string.alert_lists_pal_alert_accept_text), icon = Icons.Outlined.Check, onClick = { // TODO: Implement accept alert action @@ -565,7 +564,7 @@ private fun AlertAcceptButtons(idTestTag: String) { // Decline alert button AlertActionButton( - text = PAL_ALERT_DECLINE_TEXT, + text = context.getString(R.string.alert_lists_pal_alert_decline_text), icon = Icons.Outlined.Close, onClick = { // TODO: Implement decline alert action diff --git a/app/src/main/java/com/android/periodpals/ui/alert/CreateAlert.kt b/app/src/main/java/com/android/periodpals/ui/alert/CreateAlert.kt index 626952edf..861413c27 100644 --- a/app/src/main/java/com/android/periodpals/ui/alert/CreateAlert.kt +++ b/app/src/main/java/com/android/periodpals/ui/alert/CreateAlert.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.periodpals.R import com.android.periodpals.model.alert.Alert import com.android.periodpals.model.alert.AlertViewModel import com.android.periodpals.model.alert.AlertViewModel.Companion.LOCATION_STATE_NAME @@ -54,16 +55,6 @@ import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens import com.dsc.form_builder.TextFieldState -private const val SCREEN_TITLE = "Create Alert" -private const val INSTRUCTION_TEXT = - "Push a notification to users near you! If they are available and have the products you need, they'll be able to help you!" - -const val PRODUCT_DROPDOWN_DEFAULT_VALUE = "Please choose a product" -const val URGENCY_DROPDOWN_DEFAULT_VALUE = "Please choose an urgency level" - -private const val SUCCESSFUL_SUBMISSION_TOAST_MESSAGE = "Alert sent" -private const val SUBMISSION_BUTTON_TEXT = "Ask for Help" - private const val TAG = "CreateAlertScreen" /** @@ -94,9 +85,9 @@ fun CreateAlertScreen( formState.reset() val productState = formState.getState(PRODUCT_STATE_NAME) - productState.change(PRODUCT_DROPDOWN_DEFAULT_VALUE) + productState.change(context.getString(R.string.create_alert_product_dropdown_default_value)) val urgencyState = formState.getState(URGENCY_STATE_NAME) - urgencyState.change(URGENCY_DROPDOWN_DEFAULT_VALUE) + urgencyState.change(context.getString(R.string.create_alert_urgency_dropdown_default_value)) val locationState = formState.getState(LOCATION_STATE_NAME) val messageState = formState.getState(MESSAGE_STATE_NAME) @@ -112,6 +103,7 @@ fun CreateAlertScreen( Log.d(TAG, "Authentication data is null") }) userViewModel.loadUser( + authenticationViewModel.authUserData.value!!.uid, onFailure = { Handler(Looper.getMainLooper()).post { // used to show the Toast in the main thread Toast.makeText(context, "Error loading your data! Try again later.", Toast.LENGTH_SHORT) @@ -126,7 +118,7 @@ fun CreateAlertScreen( // Screen Scaffold( modifier = Modifier.fillMaxSize().testTag(C.Tag.CreateAlertScreen.SCREEN), - topBar = { TopAppBar(title = SCREEN_TITLE) }, + topBar = { TopAppBar(title = context.getString(R.string.create_alert_screen_title)) }, bottomBar = { BottomNavigationMenu( onTabSelect = { route -> navigationActions.navigateTo(route) }, @@ -152,7 +144,7 @@ fun CreateAlertScreen( ) { // Instruction text Text( - text = INSTRUCTION_TEXT, + text = context.getString(R.string.create_alert_instruction_text), modifier = Modifier.testTag(AlertInputs.INSTRUCTION_TEXT), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, @@ -178,7 +170,7 @@ fun CreateAlertScreen( // "Ask for Help" button ActionButton( - buttonText = SUBMISSION_BUTTON_TEXT, + buttonText = context.getString(R.string.create_alert_submission_button_text), onClick = { val errorMessage = when { @@ -208,7 +200,11 @@ fun CreateAlertScreen( onSuccess = { Log.d(TAG, "Alert created") }, onFailure = { e -> Log.e(TAG, "createAlert: fail to create alert: ${e.message}") }, ) - Toast.makeText(context, SUCCESSFUL_SUBMISSION_TOAST_MESSAGE, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.create_alert_toast_successful_submission_message), + Toast.LENGTH_SHORT) + .show() navigationActions.navigateTo(Screen.ALERT_LIST) }, colors = getFilledPrimaryContainerButtonColors(), diff --git a/app/src/main/java/com/android/periodpals/ui/alert/EditAlert.kt b/app/src/main/java/com/android/periodpals/ui/alert/EditAlert.kt index 0c6f11b8c..cb7d6f7b2 100644 --- a/app/src/main/java/com/android/periodpals/ui/alert/EditAlert.kt +++ b/app/src/main/java/com/android/periodpals/ui/alert/EditAlert.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign +import com.android.periodpals.R import com.android.periodpals.model.alert.Alert import com.android.periodpals.model.alert.AlertViewModel import com.android.periodpals.model.alert.AlertViewModel.Companion.LOCATION_STATE_NAME @@ -49,18 +50,6 @@ import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens import com.dsc.form_builder.TextFieldState -private const val SCREEN_TITLE = "Edit Your Alert" -private const val INSTRUCTION_TEXT = - "Edit, delete or resolve your push notification alert for nearby pals." + - " You can leave a review for the sender when you resolve." - -private const val DELETE_BUTTON_TEXT = "Delete" -private const val SAVE_BUTTON_TEXT = "Save" -private const val RESOLVE_BUTTON_TEXT = "Resolve" - -private const val SUCCESSFUL_UPDATE_TOAST_MESSAGE = "Alert updated" -private const val NOT_IMPLEMENTED_YET_TOAST_MESSAGE = "This feature is not implemented yet" - private const val TAG = "EditAlertScreen" /** @@ -102,7 +91,7 @@ fun EditAlertScreen( modifier = Modifier.fillMaxSize().testTag(EditAlertScreen.SCREEN), topBar = { TopAppBar( - title = SCREEN_TITLE, + title = context.getString(R.string.edit_alert_screen_title), backButton = true, onBackButtonClick = { navigationActions.navigateTo(Screen.ALERT_LIST) }, ) @@ -124,7 +113,7 @@ fun EditAlertScreen( // Instruction text Text( - text = INSTRUCTION_TEXT, + text = context.getString(R.string.edit_alert_instruction_text), modifier = Modifier.testTag(AlertInputs.INSTRUCTION_TEXT), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, @@ -154,7 +143,7 @@ fun EditAlertScreen( verticalAlignment = Alignment.CenterVertically, ) { ActionButton( - buttonText = DELETE_BUTTON_TEXT, + buttonText = context.getString(R.string.edit_alert_delete_button_text), onClick = { alertViewModel.deleteAlert( alert.id, @@ -177,7 +166,7 @@ fun EditAlertScreen( ) ActionButton( - buttonText = SAVE_BUTTON_TEXT, + buttonText = context.getString(R.string.edit_alert_save_button_text), onClick = { val errorMessage = when { @@ -192,7 +181,11 @@ fun EditAlertScreen( return@ActionButton } - Toast.makeText(context, SUCCESSFUL_UPDATE_TOAST_MESSAGE, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.edit_alert_toast_successful_update), + Toast.LENGTH_SHORT) + .show() val newAlert = Alert( id = alert.id, @@ -219,10 +212,14 @@ fun EditAlertScreen( ) ActionButton( - buttonText = RESOLVE_BUTTON_TEXT, + buttonText = context.getString(R.string.edit_alert_resolve_button_text), onClick = { // TODO: resolve alert - Toast.makeText(context, NOT_IMPLEMENTED_YET_TOAST_MESSAGE, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.edit_alert_toast_not_implemented_yet), + Toast.LENGTH_SHORT) + .show() navigationActions.navigateTo(Screen.ALERT_LIST) }, colors = getFilledPrimaryContainerButtonColors(), diff --git a/app/src/main/java/com/android/periodpals/ui/authentication/SignIn.kt b/app/src/main/java/com/android/periodpals/ui/authentication/SignIn.kt index 65c0554d1..d3e5e7b6d 100644 --- a/app/src/main/java/com/android/periodpals/ui/authentication/SignIn.kt +++ b/app/src/main/java/com/android/periodpals/ui/authentication/SignIn.kt @@ -62,17 +62,6 @@ import kotlinx.coroutines.launch private const val DEFAULT_IS_PASSWORD_VISIBLE = false -private const val SIGN_IN_INSTRUCTION = "Sign in to your account" -private const val SIGN_IN_BUTTON_TEXT = "Sign in" -private const val CONTINUE_WITH_TEXT = "Or continue with" -private const val SIGN_UP_WITH_GOOGLE = "Sign in with Google" -private const val NO_ACCOUNT_TEXT = "Not registered yet? " -private const val SIGN_UP_TEXT = "Sign up here!" - -private const val SUCCESSFUL_SIGN_IN_TOAST = "Login Successful" -private const val FAILED_SIGN_IN_TOAST = "Login Failed" -private const val INVALID_ATTEMPT_TOAST = "Invalid email or password." - /** * Composable function that displays the Sign In screen. * @@ -116,7 +105,7 @@ fun SignInScreen( Text( modifier = Modifier.fillMaxWidth().wrapContentHeight().testTag(SignInScreen.INSTRUCTION_TEXT), - text = SIGN_IN_INSTRUCTION, + text = context.getString(R.string.sign_in_instruction), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge, @@ -137,7 +126,7 @@ fun SignInScreen( ) AuthenticationSubmitButton( - text = SIGN_IN_BUTTON_TEXT, + text = context.getString(R.string.sign_in_button_text), onClick = { attemptSignIn( emailState = emailState, @@ -155,7 +144,7 @@ fun SignInScreen( Modifier.fillMaxWidth() .wrapContentHeight() .testTag(SignInScreen.CONTINUE_WITH_TEXT), - text = CONTINUE_WITH_TEXT, + text = context.getString(R.string.sign_in_continue_with_text), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge, @@ -165,8 +154,8 @@ fun SignInScreen( } NavigateBetweenAuthScreens( - NO_ACCOUNT_TEXT, - SIGN_UP_TEXT, + context.getString(R.string.sign_in_no_account_text), + context.getString(R.string.sign_in_sign_up_text), Screen.SIGN_UP, SignInScreen.NOT_REGISTERED_NAV_LINK, navigationActions) @@ -214,7 +203,7 @@ fun AuthenticationGoogleButton( ) Text( modifier = Modifier.wrapContentSize(), - text = SIGN_UP_WITH_GOOGLE, + text = context.getString(R.string.sign_in_sign_up_with_google), fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium, ) @@ -240,7 +229,9 @@ private fun attemptSignIn( navigationActions: NavigationActions, ) { if (!emailState.validate() || !passwordState.validate()) { - Toast.makeText(context, INVALID_ATTEMPT_TOAST, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, context.getString(R.string.sign_in_toast_invalid_attempt), Toast.LENGTH_SHORT) + .show() return } @@ -249,14 +240,22 @@ private fun attemptSignIn( userPassword = passwordState.value, onSuccess = { Handler(Looper.getMainLooper()).post { - Toast.makeText(context, SUCCESSFUL_SIGN_IN_TOAST, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.sign_in_toast_successful_sign_in), + Toast.LENGTH_SHORT) + .show() } PushNotificationsServiceImpl().createDeviceToken() navigationActions.navigateTo(Screen.PROFILE) }, onFailure = { Handler(Looper.getMainLooper()).post { - Toast.makeText(context, FAILED_SIGN_IN_TOAST, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.sign_in_toast_failed_sign_in), + Toast.LENGTH_SHORT) + .show() } }, ) diff --git a/app/src/main/java/com/android/periodpals/ui/authentication/SignUp.kt b/app/src/main/java/com/android/periodpals/ui/authentication/SignUp.kt index 04a1973eb..5ffa9ebf0 100644 --- a/app/src/main/java/com/android/periodpals/ui/authentication/SignUp.kt +++ b/app/src/main/java/com/android/periodpals/ui/authentication/SignUp.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign +import com.android.periodpals.R import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.resources.C.Tag.AuthenticationScreens.SignUpScreen import com.android.periodpals.services.PushNotificationsServiceImpl @@ -43,18 +44,6 @@ import com.dsc.form_builder.TextFieldState private const val DEFAULT_IS_PASSWORD_VISIBLE = false -private const val SIGN_UP_INSTRUCTION = "Create your account" -private const val CONFIRM_PASSWORD_INSTRUCTION = "Confirm your password" -private const val SIGN_UP_BUTTON_TEXT = "Sign up" - -private const val NOT_MATCHING_PASSWORD_ERROR_MESSAGE = "Passwords do not match" -private const val ALREADY_ACCOUNT_TEXT = "Already registered ? " -private const val SIGN_IN_TEXT = "Sign in!" - -private const val SUCCESSFUL_SIGN_UP_TOAST = "Account Creation Successful" -private const val FAILED_SIGN_UP_TOAST = "Account Creation Failed" -private const val INVALID_ATTEMPT_TOAST = "Invalid email or password" - /** * A composable function that displays the sign-up screen. * @@ -101,7 +90,7 @@ fun SignUpScreen( Text( modifier = Modifier.fillMaxWidth().wrapContentHeight().testTag(SignUpScreen.INSTRUCTION_TEXT), - text = SIGN_UP_INSTRUCTION, + text = context.getString(R.string.sign_up_instruction), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge, @@ -126,7 +115,7 @@ fun SignUpScreen( Modifier.fillMaxWidth() .wrapContentHeight() .testTag(SignUpScreen.CONFIRM_PASSWORD_TEXT), - text = CONFIRM_PASSWORD_INSTRUCTION, + text = context.getString(R.string.sign_up_confirm_password_instruction), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge, @@ -146,7 +135,7 @@ fun SignUpScreen( ) AuthenticationSubmitButton( - text = SIGN_UP_BUTTON_TEXT, + text = context.getString(R.string.sign_up_button_text), onClick = { attemptSignUp( emailState = emailState, @@ -162,8 +151,8 @@ fun SignUpScreen( } NavigateBetweenAuthScreens( - ALREADY_ACCOUNT_TEXT, - SIGN_IN_TEXT, + context.getString(R.string.sign_up_already_account_text), + context.getString(R.string.sign_up_sign_in_text), Screen.SIGN_IN, SignUpScreen.ALREADY_REGISTERED_NAV_LINK, navigationActions) @@ -191,12 +180,17 @@ private fun attemptSignUp( ) { // strange if statements, but necessary to show the proper error messages if (!emailState.validate() || !passwordState.validate()) { - Toast.makeText(context, INVALID_ATTEMPT_TOAST, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, context.getString(R.string.sign_up_toast_invalid_attempt), Toast.LENGTH_SHORT) + .show() return } if (!confirmPasswordState.validate() || passwordState.value != confirmPasswordState.value) { - confirmPasswordState.errorMessage = NOT_MATCHING_PASSWORD_ERROR_MESSAGE - Toast.makeText(context, INVALID_ATTEMPT_TOAST, Toast.LENGTH_SHORT).show() + confirmPasswordState.errorMessage = + context.getString(R.string.sign_up_not_matching_password_error_message) + Toast.makeText( + context, context.getString(R.string.sign_up_toast_invalid_attempt), Toast.LENGTH_SHORT) + .show() return } @@ -205,14 +199,22 @@ private fun attemptSignUp( userPassword = passwordState.value, onSuccess = { Handler(Looper.getMainLooper()).post { - Toast.makeText(context, SUCCESSFUL_SIGN_UP_TOAST, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.sign_up_toast_successful_sign_up), + Toast.LENGTH_SHORT) + .show() } PushNotificationsServiceImpl().createDeviceToken() navigationActions.navigateTo(Screen.CREATE_PROFILE) }, onFailure = { _: Exception -> Handler(Looper.getMainLooper()).post { - Toast.makeText(context, FAILED_SIGN_UP_TOAST, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.sign_up_toast_failed_sign_up), + Toast.LENGTH_SHORT) + .show() } }, ) diff --git a/app/src/main/java/com/android/periodpals/ui/chat/Chat.kt b/app/src/main/java/com/android/periodpals/ui/chat/Chat.kt deleted file mode 100644 index 7172f1817..000000000 --- a/app/src/main/java/com/android/periodpals/ui/chat/Chat.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.android.periodpals.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.android.periodpals.model.chat.ChatViewModel -import com.android.periodpals.ui.navigation.NavigationActions -import com.android.periodpals.ui.navigation.TopAppBar -import com.android.periodpals.ui.theme.dimens - -private const val SCREEN_TITLE = "ChatScreen" - -/** - * Screen that displays the top app bar, bottom navigation bar and the chat. - * - * @param chatViewModel The ViewModel for managing chat-related data and operations. - * @param navigationActions The actions for navigating between screens. - */ -@Composable -fun ChatScreen(chatViewModel: ChatViewModel, navigationActions: NavigationActions) { - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = SCREEN_TITLE, - backButton = true, - onBackButtonClick = { navigationActions.goBack() }) - }, - ) { paddingValues -> - Column( - modifier = - Modifier.fillMaxSize() - .padding(paddingValues) - .padding( - horizontal = MaterialTheme.dimens.medium3, - vertical = MaterialTheme.dimens.small3, - ) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = - Arrangement.spacedBy(MaterialTheme.dimens.small2, Alignment.CenterVertically), - ) { - // TODO: delete when implementing the screen - Text("Chat Screen", modifier = Modifier.fillMaxSize()) - } - } -} diff --git a/app/src/main/java/com/android/periodpals/ui/components/ProfileComponents.kt b/app/src/main/java/com/android/periodpals/ui/components/ProfileComponents.kt index 83ff33c26..be5a4e783 100644 --- a/app/src/main/java/com/android/periodpals/ui/components/ProfileComponents.kt +++ b/app/src/main/java/com/android/periodpals/ui/components/ProfileComponents.kt @@ -28,9 +28,11 @@ import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign +import com.android.periodpals.model.user.MIN_AGE import com.android.periodpals.model.user.User import com.android.periodpals.model.user.UserViewModel import com.android.periodpals.resources.C.Tag.ProfileScreens +import com.android.periodpals.resources.C.Tag.ProfileScreens.DOB_MIN_AGE_TEXT import com.android.periodpals.resources.ComponentColor.getFilledPrimaryContainerButtonColors import com.android.periodpals.resources.ComponentColor.getOutlinedTextFieldColors import com.android.periodpals.ui.navigation.NavigationActions @@ -49,6 +51,7 @@ private const val DOB_PLACEHOLDER = "DD/MM/YYYY" const val PROFILE_TEXT = "Your Profile" private const val DESCRIPTION_LABEL = "Description" private const val DESCRIPTION_PLACEHOLDER = "Describe yourself" +private const val MINIMUM_AGE_TEXT = "You have to be at least $MIN_AGE years old to use this app." private const val SAVE_BUTTON_TEXT = "Save" const val LOG_TAG = "CreateProfileScreen" const val LOG_FAILURE = "Failed to save profile" @@ -126,7 +129,8 @@ fun ProfileInputName(name: String, onValueChange: (String) -> Unit) { } /** - * A composable function that displays an outlined text field for the date of birth input. + * A composable function that displays an outlined text field for the date of birth input, as well + * as a text to explain the minimum age requirement. * * @param dob The current value of the date of birth input. * @param onValueChange A lambda function to handle changes to the date of birth input. @@ -153,6 +157,16 @@ fun ProfileInputDob(dob: String, onValueChange: (String) -> Unit) { }, placeholder = { Text(text = DOB_PLACEHOLDER, style = MaterialTheme.typography.labelLarge) }, ) + Text( + text = MINIMUM_AGE_TEXT, + style = MaterialTheme.typography.labelSmall, + modifier = + Modifier.wrapContentHeight() + .fillMaxWidth() + .testTag(DOB_MIN_AGE_TEXT) + .padding(top = MaterialTheme.dimens.small2), + textAlign = TextAlign.Center, + ) } /** @@ -217,8 +231,8 @@ fun ProfileSaveButton( onClick = { val errorMessage = when { - !dobState.validate() -> dobState.errorMessage !nameState.validate() -> nameState.errorMessage + !dobState.validate() -> dobState.errorMessage !descriptionState.validate() -> descriptionState.errorMessage else -> null } @@ -259,7 +273,8 @@ fun ProfileSaveButton( Toast.makeText(context, TOAST_FAILURE, Toast.LENGTH_SHORT).show() } Log.d(LOG_TAG, LOG_FAILURE) - }) + }, + ) } }, onFailure = { diff --git a/app/src/main/java/com/android/periodpals/ui/map/Map.kt b/app/src/main/java/com/android/periodpals/ui/map/Map.kt index d407b6851..421086469 100644 --- a/app/src/main/java/com/android/periodpals/ui/map/Map.kt +++ b/app/src/main/java/com/android/periodpals/ui/map/Map.kt @@ -67,18 +67,10 @@ import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Polygon private const val TAG = "MapScreen" -private const val SCREEN_TITLE = "Map" -private const val YOUR_LOCATION_MARKER_TITLE = "Your location" private const val MIN_ZOOM_LEVEL = 5.0 private const val MAX_ZOOM_LEVEL = 19.0 private const val INITIAL_ZOOM_LEVEL = 17.0 - -private const val LIGHT_TILES_URL = "https://tiles.stadiamaps.com/tiles/alidade_smooth/" -private const val DARK_TILES_URL = "https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/" -private const val DARK_TILES_NAME = "dark_tiles" -private const val LIGHT_TILES_NAME = "light_tiles" - private const val DEFAULT_RADIUS = 100.0 /** @@ -188,7 +180,7 @@ fun MapScreen( alertsOverlay = alertOverlay, location = myLocation, isDarkTheme = isDarkTheme, - ) + context = context) } Scaffold( @@ -200,7 +192,7 @@ fun MapScreen( selectedItem = navigationActions.currentRoute(), ) }, - topBar = { TopAppBar(title = SCREEN_TITLE) }, + topBar = { TopAppBar(title = context.getString(R.string.map_screen_title)) }, floatingActionButton = { Column( verticalArrangement = @@ -301,14 +293,16 @@ fun MapScreen( }, ) } + /** - * Initializes the map to a given zoom level at the user's location. + * Initializes the map with the given parameters. * * @param mapView Primary view for `osmdroid`. * @param myLocationOverlay Overlay upon which the current location marker is drawn * @param alertsOverlay Overlay upon which the alert markers are drawn * @param location GPS location of the user * @param isDarkTheme Reflects the system's theme + * @param context The context of the activity. */ private fun initializeMap( mapView: MapView, @@ -316,6 +310,7 @@ private fun initializeMap( alertsOverlay: FolderOverlay, location: Location, isDarkTheme: Boolean, + context: Context ) { mapView.apply { setMultiTouchControls(true) @@ -327,7 +322,7 @@ private fun initializeMap( this.overlays.add(myLocationOverlay) this.overlays.add(alertsOverlay) } - setTileSource(mapView = mapView, isDarkTheme = isDarkTheme) + setTileSource(mapView = mapView, isDarkTheme = isDarkTheme, context = context) } /** @@ -413,7 +408,7 @@ private fun updateMyLocationMarker( Marker(mapView).apply { position = myLocation.toGeoPoint() setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - title = YOUR_LOCATION_MARKER_TITLE + title = context.getString(R.string.map_your_location_marker_title) icon = ContextCompat.getDrawable(context, R.drawable.location) infoWindow = null // Hide the pop-up that appears when you click on a marker setOnMarkerClickListener { _, _ -> @@ -444,14 +439,19 @@ private fun updateMyLocationMarker( * * @param mapView The view of the map in which the tile source will be used * @param isDarkTheme True if the device is in dark theme + * @param context The context of the activity */ -private fun setTileSource(mapView: MapView, isDarkTheme: Boolean) { +private fun setTileSource(mapView: MapView, isDarkTheme: Boolean, context: Context) { val fileNameExtension = ".png" val tileSize = 256 - val tileName = if (isDarkTheme) DARK_TILES_NAME else LIGHT_TILES_NAME - val tileUrl = if (isDarkTheme) DARK_TILES_URL else LIGHT_TILES_URL + val tileName = + if (isDarkTheme) context.getString(R.string.dark_tiles_name) + else context.getString(R.string.light_tiles_name) + val tileUrl = + if (isDarkTheme) context.getString(R.string.dark_tiles_url) + else context.getString(R.string.light_tiles_url) val customTileSource = object : diff --git a/app/src/main/java/com/android/periodpals/ui/profile/CreateProfile.kt b/app/src/main/java/com/android/periodpals/ui/profile/CreateProfile.kt index 32c739369..d43825831 100644 --- a/app/src/main/java/com/android/periodpals/ui/profile/CreateProfile.kt +++ b/app/src/main/java/com/android/periodpals/ui/profile/CreateProfile.kt @@ -47,10 +47,6 @@ import com.android.periodpals.ui.theme.dimens import com.dsc.form_builder.TextFieldState import kotlin.math.roundToInt -private const val SCREEN_TITLE = "Create Your Account" -private const val RADIUS_EXPLANATION_TEXT = - "By specifying this radius, " + - "you can control the geographical range for receiving alerts from other users." private val DEFAULT_PROFILE_PICTURE = Uri.parse("android.resource://com.android.periodpals/${R.drawable.generic_avatar}") private const val DEFAULT_RADIUS = 500F @@ -86,7 +82,7 @@ fun CreateProfileScreen(userViewModel: UserViewModel, navigationActions: Navigat Scaffold( modifier = Modifier.fillMaxSize().testTag(CreateProfileScreen.SCREEN), - topBar = { TopAppBar(title = SCREEN_TITLE) }, + topBar = { TopAppBar(title = context.getString(R.string.create_profile_screen_title)) }, containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface, ) { paddingValues -> @@ -133,7 +129,7 @@ fun CreateProfileScreen(userViewModel: UserViewModel, navigationActions: Navigat SliderMenu(sliderPosition) { sliderPosition = (it / 100).roundToInt() * 100f } Text( - text = RADIUS_EXPLANATION_TEXT, + text = context.getString(R.string.create_profile_radius_explanation_text), style = MaterialTheme.typography.labelMedium, modifier = Modifier.wrapContentHeight() diff --git a/app/src/main/java/com/android/periodpals/ui/profile/EditProfile.kt b/app/src/main/java/com/android/periodpals/ui/profile/EditProfile.kt index 805a18523..d851e6eea 100644 --- a/app/src/main/java/com/android/periodpals/ui/profile/EditProfile.kt +++ b/app/src/main/java/com/android/periodpals/ui/profile/EditProfile.kt @@ -48,8 +48,6 @@ import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens import com.dsc.form_builder.TextFieldState -private const val SCREEN_TITLE = "Edit Your Profile" -private const val TAG = "EditProfile" private val DEFAULT_PROFILE_PICTURE = "android.resource://com.android.periodpals/${R.drawable.generic_avatar}" @@ -98,7 +96,7 @@ fun EditProfileScreen(userViewModel: UserViewModel, navigationActions: Navigatio modifier = Modifier.fillMaxSize().testTag(EditProfileScreen.SCREEN), topBar = { TopAppBar( - title = SCREEN_TITLE, + title = context.getString(R.string.edit_profile_screen_title), true, onBackButtonClick = { navigationActions.navigateTo(Screen.PROFILE) }, ) diff --git a/app/src/main/java/com/android/periodpals/ui/profile/ProfileScreen.kt b/app/src/main/java/com/android/periodpals/ui/profile/ProfileScreen.kt index a5819afcb..49497ab8b 100644 --- a/app/src/main/java/com/android/periodpals/ui/profile/ProfileScreen.kt +++ b/app/src/main/java/com/android/periodpals/ui/profile/ProfileScreen.kt @@ -26,12 +26,17 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import com.android.periodpals.R +import com.android.periodpals.model.authentication.AuthenticationViewModel +import com.android.periodpals.model.chat.ChatViewModel +import com.android.periodpals.model.user.User import com.android.periodpals.model.user.UserViewModel import com.android.periodpals.resources.C.Tag.ProfileScreens.ProfileScreen import com.android.periodpals.resources.ComponentColor.getTertiaryCardColors @@ -45,18 +50,11 @@ import com.android.periodpals.ui.navigation.Screen import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens -private const val SCREEN_TITLE = "Your Profile" private const val TAG = "ProfileScreen" -private const val DEFAULT_NAME = "Error loading name, try again later." -private const val DEFAULT_DESCRIPTION = "Error loading description, try again later." + private val DEFAULT_PROFILE_PICTURE = Uri.parse("android.resource://com.android.periodpals/${R.drawable.generic_avatar}") -private const val NEW_USER_TEXT = "New user" -private const val NUMBER_INTERACTION_TEXT = "Number of interactions: " -private const val REVIEWS_TITLE = "Reviews" -private const val NO_REVIEWS_TEXT = "No reviews yet..." - /** * A composable function that displays the user's profile screen. * @@ -65,21 +63,31 @@ private const val NO_REVIEWS_TEXT = "No reviews yet..." * menu. * * @param userViewModel The ViewModel that handles user data. + * @param authenticationViewModel The ViewModel that handles authentication data. + * @param notificationService The service that handles push notifications. + * @param chatViewModel The ViewModel that handles chat data. * @param navigationActions The navigation actions to navigate between screens. - * @sample ProfileScreen */ @Composable fun ProfileScreen( userViewModel: UserViewModel, + authenticationViewModel: AuthenticationViewModel, notificationService: PushNotificationsService, + chatViewModel: ChatViewModel, navigationActions: NavigationActions ) { val context = LocalContext.current val numberInteractions = 0 // TODO: placeholder to be replaced when we integrate it to the User data class + val userState by remember { userViewModel.user } + val userAvatar by remember { userViewModel.avatar } Log.d(TAG, "Loading user data") - userViewModel.init( + init( + userViewModel, + authenticationViewModel, + chatViewModel, + userState, onSuccess = { Log.d(TAG, "User data loaded successfully") }, onFailure = { e: Exception -> Log.d(TAG, "Error loading user data: $e") @@ -90,9 +98,6 @@ fun ProfileScreen( }, ) - val userState = userViewModel.user - val userAvatar = userViewModel.avatar - // Only executed once LaunchedEffect(Unit) { notificationService.askPermission() } @@ -100,7 +105,7 @@ fun ProfileScreen( modifier = Modifier.fillMaxSize().testTag(ProfileScreen.SCREEN), topBar = { TopAppBar( - title = SCREEN_TITLE, + title = context.getString(R.string.profile_screen_title), settingsButton = true, onSettingsButtonClick = { navigationActions.navigateTo(Screen.SETTINGS) }, editButton = true, @@ -131,12 +136,12 @@ fun ProfileScreen( Arrangement.spacedBy(MaterialTheme.dimens.small2, Alignment.CenterVertically), ) { // Profile picture - ProfilePicture(model = userAvatar.value ?: DEFAULT_PROFILE_PICTURE) + ProfilePicture(model = userAvatar ?: DEFAULT_PROFILE_PICTURE) // Name Text( modifier = Modifier.fillMaxWidth().wrapContentHeight().testTag(ProfileScreen.NAME_FIELD), - text = userState.value?.name ?: DEFAULT_NAME, + text = userState?.name ?: context.getString(R.string.profile_default_name), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleSmall, ) @@ -145,7 +150,7 @@ fun ProfileScreen( Text( modifier = Modifier.fillMaxWidth().wrapContentHeight().testTag(ProfileScreen.DESCRIPTION_FIELD), - text = userState.value?.description ?: DEFAULT_DESCRIPTION, + text = userState?.description ?: context.getString(R.string.profile_default_description), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) @@ -155,14 +160,16 @@ fun ProfileScreen( modifier = Modifier.fillMaxWidth().wrapContentHeight().testTag(ProfileScreen.CONTRIBUTION_FIELD), text = - if (numberInteractions == 0) NEW_USER_TEXT - else NUMBER_INTERACTION_TEXT + numberInteractions, + if (numberInteractions == 0) context.getString(R.string.profile_new_user) + else context.getString(R.string.profile_number_interaction_text) + numberInteractions, textAlign = TextAlign.Left, style = MaterialTheme.typography.bodyMedium, ) // Review section text - ProfileSection(text = REVIEWS_TITLE, testTag = ProfileScreen.REVIEWS_SECTION) + ProfileSection( + text = context.getString(R.string.profile_reviews_title), + testTag = ProfileScreen.REVIEWS_SECTION) // Reviews or no reviews card if (numberInteractions == 0) { @@ -183,6 +190,7 @@ fun ProfileScreen( */ @Composable private fun NoReviewCard() { + val context = LocalContext.current Card( modifier = Modifier.wrapContentSize().testTag(ProfileScreen.NO_REVIEWS_CARD), shape = RoundedCornerShape(size = MaterialTheme.dimens.cardRoundedSize), @@ -203,9 +211,49 @@ private fun NoReviewCard() { ) Text( modifier = Modifier.wrapContentSize().testTag(ProfileScreen.NO_REVIEWS_TEXT), - text = NO_REVIEWS_TEXT, + text = context.getString(R.string.profile_no_reviews_text), style = MaterialTheme.typography.bodyMedium, ) } } } + +/** + * Initializes the user profile. + * + * This function loads the user profile and downloads the user's profile picture. + * + * @param userViewModel The ViewModel that handles user data. + * @param authenticationViewModel The ViewModel that handles authentication data. + * @param chatViewModel The ViewModel that handles chat data. + * @param userState The user's state. + * @param onSuccess Callback function to be called when the user profile is successfully loaded. + * @param onFailure Callback function to be called when there is an error loading the user profile. + */ +fun init( + userViewModel: UserViewModel, + authenticationViewModel: AuthenticationViewModel, + chatViewModel: ChatViewModel, + userState: User?, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit +) { + // Load the user's authentication data and connect the user to the chat services + authenticationViewModel.loadAuthenticationUserData( + onSuccess = { + Log.d(TAG, "Authentication data loaded successfully") + chatViewModel.connectUser(userState, authenticationViewModel = authenticationViewModel) + }, + onFailure = { Log.d(TAG, "Authentication data is null") }) + userViewModel.loadUser( + authenticationViewModel.authUserData.value!!.uid, + onSuccess = { + userViewModel.user.value?.let { + userViewModel.downloadFile( + it.imageUrl, + onSuccess = { onSuccess() }, + onFailure = { e: Exception -> onFailure(Exception(e)) }) + } + }, + onFailure = { e: Exception -> onFailure(Exception(e)) }) +} 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 3d7a11344..7bab71623 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 @@ -57,6 +57,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign 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.UserViewModel import com.android.periodpals.resources.C.Tag.SettingsScreen @@ -69,32 +70,11 @@ import com.android.periodpals.ui.navigation.Screen import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens -private const val SCREEN_TITLE = "My Settings" - -// Comments -private const val COMMENT_NOTIFICATIONS = "Notify me when a pal needs ..." -private const val COMMENT_ORGANIC = "Which are ..." - -// Notifications -private const val NOTIF_PALS = "Pals’ Notifications" -private const val NOTIF_PADS = "Pads" -private const val NOTIF_TAMPONS = "Tampons" -private const val NOTIF_ORGANIC = "Organic" - // Themes -private const val THEME_LABEL = "Theme" private const val THEME_SYSTEM = "System" private const val THEME_LIGHT = "Light Mode" private const val THEME_DARK = "Dark Mode" -// account management -private const val ACCOUNT_PASSWORD = "Change Password" -private const val ACCOUNT_SIGN_OUT = "Sign Out" -private const val ACCOUNT_DELETE = "Delete Account" - -// Dialog -private const val DIALOG_TEXT = "Are you sure you want to delete your account?" - // Dropdown choices private val THEME_DROPDOWN_CHOICES = listOf( @@ -105,26 +85,6 @@ private val THEME_DROPDOWN_CHOICES = // Log messages private const val LOG_SETTINGS_TAG = "SettingsScreen" -private const val LOG_SETTINGS_SUCCESS_SIGN_OUT = "Sign out successful" -private const val LOG_SETTINGS_FAILURE_SIGN_OUT = "Failed to sign out" - -private const val LOG_SETTINGS_SUCCESS_DELETE = "Account deleted successfully" -private const val LOG_SETTINGS_FAILURE_DELETE = "Failed to delete account" - -private const val LOG_SETTINGS_SUCCESS_LOAD_DATA = - "user data loaded successfully, deleting the user" -private const val LOG_SETTINGS_FAILURE_LOAD_DATA = "failed to load user data, can't delete the user" - -// Toast messages - -private const val TOAST_SETTINGS_SUCCESS_SIGN_OUT = "Sign out successful" -private const val TOAST_SETTINGS_FAILURE_SIGN_OUT = "Failed to sign out" - -private const val TOAST_SETTINGS_SUCCESS_DELETE = "Account deleted successfully" -private const val TOAST_SETTINGS_FAILURE_DELETE = "Failed to delete account" - -private const val TOAST_LOAD_DATA_FAILURE = "Failed loading user authentication data" - /** * A composable function that displays the Settings screen, where users can manage their * notifications, themes, and account settings. @@ -143,7 +103,7 @@ private const val TOAST_LOAD_DATA_FAILURE = "Failed loading user authentication fun SettingsScreen( userViewModel: UserViewModel, authenticationViewModel: AuthenticationViewModel, - navigationActions: NavigationActions + navigationActions: NavigationActions, ) { // notifications states @@ -176,7 +136,7 @@ fun SettingsScreen( modifier = Modifier.fillMaxSize().testTag(SettingsScreen.SCREEN), topBar = { TopAppBar( - title = SCREEN_TITLE, + title = context.getString(R.string.settings_screen_title), true, onBackButtonClick = { navigationActions.goBack() }, ) @@ -201,7 +161,7 @@ fun SettingsScreen( // notification section SettingsContainer(testTag = SettingsScreen.NOTIFICATIONS_CONTAINER) { SettingsSwitchRow( - text = NOTIF_PALS, + text = context.getString(R.string.settings_notif_pals), isChecked = receiveNotifications, onCheckedChange = { receiveNotifications = it }, textTestTag = SettingsScreen.PALS_TEXT, @@ -211,22 +171,25 @@ fun SettingsScreen( color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.testTag(SettingsScreen.HORIZONTAL_DIVIDER)) SettingsDescription( - text = COMMENT_NOTIFICATIONS, testTag = SettingsScreen.NOTIFICATIONS_DESCRIPTION) + text = context.getString(R.string.settings_comment_notifications), + testTag = SettingsScreen.NOTIFICATIONS_DESCRIPTION) SettingsSwitchRow( - text = NOTIF_PADS, + text = context.getString(R.string.settings_notif_pads), isChecked = receiveNotifications && padsNotifications, onCheckedChange = { padsNotifications = it }, textTestTag = SettingsScreen.PADS_TEXT, switchTestTag = SettingsScreen.PADS_SWITCH) SettingsSwitchRow( - text = NOTIF_TAMPONS, + text = context.getString(R.string.settings_notif_tampons), isChecked = receiveNotifications && tamponsNotifications, onCheckedChange = { tamponsNotifications = it }, textTestTag = SettingsScreen.TAMPONS_TEXT, switchTestTag = SettingsScreen.TAMPONS_SWITCH) - SettingsDescription(COMMENT_ORGANIC, SettingsScreen.ORGANIC_DESCRIPTION) + SettingsDescription( + context.getString(R.string.settings_comment_organic), + SettingsScreen.ORGANIC_DESCRIPTION) SettingsSwitchRow( - text = NOTIF_ORGANIC, + text = context.getString(R.string.settings_notif_organic), isChecked = receiveNotifications && organicNotifications, onCheckedChange = { organicNotifications = it }, textTestTag = SettingsScreen.ORGANIC_TEXT, @@ -245,7 +208,11 @@ fun SettingsScreen( textStyle = MaterialTheme.typography.labelLarge, value = theme, onValueChange = {}, - label = { Text(THEME_LABEL, style = MaterialTheme.typography.labelMedium) }, + label = { + Text( + context.getString(R.string.settings_theme_label), + style = MaterialTheme.typography.labelMedium) + }, singleLine = true, readOnly = true, leadingIcon = { Icon(icon, contentDescription = null) }, @@ -292,34 +259,38 @@ fun SettingsScreen( // account management section SettingsContainer(testTag = SettingsScreen.ACCOUNT_MANAGEMENT_CONTAINER) { SettingsIconRow( - text = ACCOUNT_PASSWORD, + text = context.getString(R.string.settings_account_password), onClick = {}, icon = Icons.Outlined.Key, textTestTag = SettingsScreen.PASSWORD_TEXT, iconTestTag = SettingsScreen.PASSWORD_ICON, ) SettingsIconRow( - text = ACCOUNT_SIGN_OUT, + text = context.getString(R.string.settings_account_sign_out), onClick = { authenticationViewModel.logOut( onSuccess = { Handler(Looper.getMainLooper()) .post { // used to show the Toast on the main thread Toast.makeText( - context, TOAST_SETTINGS_SUCCESS_SIGN_OUT, Toast.LENGTH_SHORT) + context, + context.getString(R.string.settings_toast_success_sign_out), + Toast.LENGTH_SHORT) .show() } - Log.d(LOG_SETTINGS_TAG, LOG_SETTINGS_SUCCESS_SIGN_OUT) + Log.d(LOG_SETTINGS_TAG, "Sign out successful") navigationActions.navigateTo(Screen.SIGN_IN) }, onFailure = { Handler(Looper.getMainLooper()) .post { // used to show the Toast on the main thread Toast.makeText( - context, TOAST_SETTINGS_FAILURE_SIGN_OUT, Toast.LENGTH_SHORT) + context, + context.getString(R.string.settings_toast_failure_sign_out), + Toast.LENGTH_SHORT) .show() } - Log.d(LOG_SETTINGS_TAG, LOG_SETTINGS_FAILURE_SIGN_OUT) + Log.d(LOG_SETTINGS_TAG, "Failed to sign out") }) }, icon = Icons.AutoMirrored.Outlined.Logout, @@ -327,7 +298,7 @@ fun SettingsScreen( iconTestTag = SettingsScreen.SIGN_OUT_ICON, ) SettingsIconRow( - text = ACCOUNT_DELETE, + text = context.getString(R.string.settings_account_delete), onClick = { showDialog = true }, icon = Icons.Outlined.Delete, textTestTag = SettingsScreen.DELETE_ACCOUNT_TEXT, @@ -502,7 +473,7 @@ private fun DeleteAccountDialog( ) Text( modifier = Modifier.wrapContentSize().testTag(SettingsScreen.CARD_TEXT), - text = DIALOG_TEXT, + text = context.getString(R.string.settings_dialog_text), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, ) @@ -511,7 +482,8 @@ private fun DeleteAccountDialog( onClick = { authenticationViewModel.loadAuthenticationUserData( onSuccess = { - Log.d(LOG_SETTINGS_TAG, LOG_SETTINGS_SUCCESS_LOAD_DATA) + Log.d( + LOG_SETTINGS_TAG, "user data loaded successfully, deleting the user") userViewModel.deleteUser( authenticationViewModel.authUserData.value!!.uid, onSuccess = { @@ -519,11 +491,12 @@ private fun DeleteAccountDialog( .post { // used to show the Toast on the main thread Toast.makeText( context, - TOAST_SETTINGS_SUCCESS_DELETE, + context.getString( + R.string.settings_toast_success_delete), Toast.LENGTH_SHORT) .show() } - Log.d(LOG_SETTINGS_TAG, LOG_SETTINGS_SUCCESS_DELETE) + Log.d(LOG_SETTINGS_TAG, "Account deleted successfully") navigationActions.navigateTo(Screen.SIGN_IN) }, onFailure = { @@ -531,20 +504,25 @@ private fun DeleteAccountDialog( .post { // used to show the Toast on the main thread Toast.makeText( context, - TOAST_SETTINGS_FAILURE_DELETE, + context.getString( + R.string.settings_toast_failure_delete), Toast.LENGTH_SHORT) .show() } - Log.d(LOG_SETTINGS_TAG, LOG_SETTINGS_FAILURE_DELETE) + 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, TOAST_LOAD_DATA_FAILURE, Toast.LENGTH_SHORT) + Toast.makeText( + context, + context.getString( + R.string.settings_toast_load_data_failure), + Toast.LENGTH_SHORT) .show() } - Log.d(LOG_SETTINGS_TAG, LOG_SETTINGS_FAILURE_LOAD_DATA) + Log.d(LOG_SETTINGS_TAG, "failed to load user data, can't delete the user") }) }, colors = diff --git a/app/src/main/java/com/android/periodpals/ui/theme/Theme.kt b/app/src/main/java/com/android/periodpals/ui/theme/Theme.kt index 92b9b95ff..771b52a2f 100644 --- a/app/src/main/java/com/android/periodpals/ui/theme/Theme.kt +++ b/app/src/main/java/com/android/periodpals/ui/theme/Theme.kt @@ -27,7 +27,7 @@ import com.android.periodpals.resources.MediumTypography import com.android.periodpals.resources.PeriodPalsColor // Largest compact S width -private const val COMPACT_S = 360 +private const val COMPACT_S = 300 // Largest compact M width private const val COMPACT_M = 420 diff --git a/app/src/main/java/com/android/periodpals/ui/timer/TimerScreen.kt b/app/src/main/java/com/android/periodpals/ui/timer/TimerScreen.kt index 024db423f..add22f6a8 100644 --- a/app/src/main/java/com/android/periodpals/ui/timer/TimerScreen.kt +++ b/app/src/main/java/com/android/periodpals/ui/timer/TimerScreen.kt @@ -1,7 +1,6 @@ -@file:Suppress("DEPRECATION") - package com.android.periodpals.ui.timer +import android.content.Context import android.util.Log import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.TweenSpec @@ -42,8 +41,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign +import com.android.periodpals.R import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.timer.COUNTDOWN_DURATION import com.android.periodpals.model.timer.HOUR @@ -61,20 +62,8 @@ import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens import kotlin.math.abs -private const val SCREEN_TITLE = "Tampon Timer" private const val TAG = "TimerScreen" -private const val DISPLAYED_TEXT_START = - "Start your tampon timer.\n" + "You’ll be reminded to change it !" -private const val USEFUL_TIP_TEXT = - "Leaving a tampon in for over 3-4 hours too often can cause irritation and infections." + - " Regular changes are essential to avoid risks." + - " Choosing cotton or natural tampons helps reduce irritation and improve hygiene." - -private const val RESET = "RESET" -private const val STOP = "STOP" -private const val START = "START" - /** * Composable function for the Timer screen. * @@ -94,6 +83,8 @@ fun TimerScreen( val isRunning by remember { mutableStateOf(timerViewModel.isRunning) } val userAverageTimer by remember { mutableStateOf(timerViewModel.userAverageTimer) } + val context = LocalContext.current + authenticationViewModel.loadAuthenticationUserData( onSuccess = { Log.d(TAG, "Successfully loaded user data") @@ -102,7 +93,7 @@ fun TimerScreen( Scaffold( modifier = Modifier.fillMaxSize().testTag(TimerScreen.SCREEN), - topBar = { TopAppBar(title = SCREEN_TITLE) }, + topBar = { TopAppBar(title = context.getString(R.string.timer_screen_title)) }, bottomBar = { BottomNavigationMenu( onTabSelect = { route -> navigationActions.navigateTo(route) }, @@ -128,7 +119,9 @@ fun TimerScreen( // Displayed text Text( - text = activeTimer.value?.instructionText ?: DISPLAYED_TEXT_START, + text = + activeTimer.value?.instructionText + ?: context.getString(R.string.timer_displayed_text_start), modifier = Modifier.testTag(TimerScreen.DISPLAYED_TEXT), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, @@ -146,7 +139,7 @@ fun TimerScreen( if (isRunning.value) { // Reset Button TimerButton( - text = RESET, + text = context.getString(R.string.timer_reset), modifier = Modifier.testTag(TimerScreen.RESET_BUTTON), onClick = { timerViewModel.resetTimer() }, colors = getInverseSurfaceButtonColors(), @@ -154,7 +147,7 @@ fun TimerScreen( // Stop Button TimerButton( - text = STOP, + text = context.getString(R.string.timer_stop), modifier = Modifier.testTag(TimerScreen.STOP_BUTTON), onClick = { timerViewModel.stopTimer(uid = authUserData.value?.uid ?: "") }, colors = getErrorButtonColors(), @@ -162,7 +155,7 @@ fun TimerScreen( } else { // Start Button TimerButton( - text = START, + text = context.getString(R.string.timer_start), modifier = Modifier.testTag(TimerScreen.START_BUTTON), onClick = { timerViewModel.startTimer() }, colors = getFilledPrimaryButtonColors(), @@ -171,7 +164,7 @@ fun TimerScreen( } // Useful tip - UsefulTip() + UsefulTip(context) // Average time Text( @@ -336,7 +329,7 @@ fun TimerButton( * - Icon: `TimerScreen.USEFUL_TIP` */ @Composable -fun UsefulTip() { +fun UsefulTip(context: Context) { Row( modifier = Modifier.testTag(TimerScreen.USEFUL_TIP), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimens.small2)) { @@ -357,7 +350,7 @@ fun UsefulTip() { thickness = MaterialTheme.dimens.borderLine, color = MaterialTheme.colorScheme.outlineVariant) Text( - text = USEFUL_TIP_TEXT, + text = context.getString(R.string.timer_useful_tip_text), modifier = Modifier.testTag(TimerScreen.USEFUL_TIP_TEXT), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 110e9e6a0..35b21adba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,137 @@ PeriodPals 683519755288-5dhnhoqhelf1lfsdpb9l1a8lghe445c5.apps.googleusercontent.com + + + Sign in to your account + Sign in + Or continue with + Sign in with Google + Not registered yet?\u00A0 + Sign up here! + + Login Successful + Login Failed + Invalid email or password. + + + Create your account + Confirm your password + Sign up + Already registered?\u00A0 + Sign in! + + Account Creation Successful + Account Creation Failed + Invalid email or password + + Passwords do not match + + + Create Your Account + + By specifying this radius, you can control the geographical range for receiving alerts from other users. + + + + Edit Your Profile + + + Your Profile + Error loading name, try again later. + Error loading description, try again later. + New user + Number of interactions: + Reviews + No reviews yet... + + + My Settings + + Notify me when a pal needs ... + Which are ... + + Pals’ Notifications + Pads + Tampons + Organic + + Theme + + Change Password + Sign Out + Delete Account + + Are you sure you want to delete your account? + + Sign out successful + Failed to sign out + Account deleted successfully + Failed to delete account + Failed loading user authentication data + + + Create Alert + + Push a notification to users near you! If they are available and have the products you need, + they\'ll be able to help you! + + + Please choose a product + Please choose an urgency level + Ask for Help + + Alert sent + + + Edit Your Alert + + Edit, delete or resolve your push notification alert for nearby pals. + + + Delete + Save + Resolve + + Alert updated + This feature is not implemented yet + + + Alert Lists + My Alerts + Pals Alerts + + You haven\'t asked for help yet ! + No pal needs help yet ! + + Edit + Accept + Decline + + No Preference + + + Map + Your location + + + https://tiles.stadiamaps.com/tiles/alidade_smooth/ + https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/ + + + light_tiles + dark_tiles + + + Tampon Timer + Start your tampon timer.\nYou’ll be reminded to change it! + + Leaving a tampon in for over 3-4 hours too often can cause irritation and infections. + Regular changes are essential to avoid risks. + Choosing cotton or natural tampons helps reduce irritation and improve hygiene. + + START + RESET + STOP + \ No newline at end of file diff --git a/app/src/test/java/com/android/periodpals/model/authentication/AuthenticationModelSupabaseTest.kt b/app/src/test/java/com/android/periodpals/model/authentication/AuthenticationModelSupabaseTest.kt index e647413b1..19de55e52 100644 --- a/app/src/test/java/com/android/periodpals/model/authentication/AuthenticationModelSupabaseTest.kt +++ b/app/src/test/java/com/android/periodpals/model/authentication/AuthenticationModelSupabaseTest.kt @@ -15,7 +15,6 @@ import org.junit.Test import org.mockito.Mock import org.mockito.Mockito.anyString import org.mockito.Mockito.doThrow -import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.mockito.kotlin.any @@ -24,13 +23,9 @@ import org.mockito.kotlin.anyOrNull class AuthenticationModelSupabaseTest { @Mock private lateinit var supabaseClient: SupabaseClient - @Mock private lateinit var pluginManagerWrapper: PluginManagerWrapper - @Mock private lateinit var auth: Auth - @Mock private lateinit var authConfig: AuthConfig - @Mock private lateinit var mockUserInfo: UserInfo private lateinit var authModel: AuthenticationModelSupabase @@ -48,7 +43,6 @@ class AuthenticationModelSupabaseTest { @Before fun setUp() { MockitoAnnotations.openMocks(this) - auth = mock(Auth::class.java) `when`(auth.config).thenReturn(authConfig) `when`(pluginManagerWrapper.getAuthPlugin()).thenReturn(auth) @@ -163,7 +157,7 @@ class AuthenticationModelSupabaseTest { @Test fun `currentAuthUser success`() = runBlocking { - val expected: UserInfo = UserInfo(aud = aud, id = id) + val expected = UserInfo(aud = aud, id = id) `when`(auth.currentUserOrNull()).thenReturn(expected) authModel.currentAuthenticationUser( diff --git a/app/src/test/java/com/android/periodpals/model/authentication/AuthenticationViewModelTest.kt b/app/src/test/java/com/android/periodpals/model/authentication/AuthenticationViewModelTest.kt index 956fff22d..a4b19c67d 100644 --- a/app/src/test/java/com/android/periodpals/model/authentication/AuthenticationViewModelTest.kt +++ b/app/src/test/java/com/android/periodpals/model/authentication/AuthenticationViewModelTest.kt @@ -28,12 +28,12 @@ class AuthenticationViewModelTest { @ExperimentalCoroutinesApi @get:Rule var mainCoroutineRule = MainCoroutineRule() companion object { - private val email = "test@example.com" - private val password = "password" - private val aud = "test_aud" - private val id = "test_id" - private val googleIdToken = "test_token" - private val rawNonce = "test_nonce" + private const val EMAIL = "test@example.com" + private const val PASSWORD = "password" + private const val AUD = "test_aud" + private const val ID = "test_id" + private const val GOOGLE_ID_TOKEN = "test_token" + private const val RAW_NONCE = "test_nonce" } @Before @@ -48,7 +48,7 @@ class AuthenticationViewModelTest { .`when`(authModel) .register(any(), any(), any<() -> Unit>(), any<(Exception) -> Unit>()) - authenticationViewModel.signUpWithEmail(userEmail = email, userPassword = password) + authenticationViewModel.signUpWithEmail(userEmail = EMAIL, userPassword = PASSWORD) val result = when (authenticationViewModel.userAuthenticationState.value) { @@ -64,7 +64,7 @@ class AuthenticationViewModelTest { .`when`(authModel) .register(any(), any(), any<() -> Unit>(), any<(Exception) -> Unit>()) - authenticationViewModel.signUpWithEmail(userEmail = email, userPassword = password) + authenticationViewModel.signUpWithEmail(userEmail = EMAIL, userPassword = PASSWORD) val result = when (authenticationViewModel.userAuthenticationState.value) { @@ -80,7 +80,7 @@ class AuthenticationViewModelTest { .`when`(authModel) .login(any(), any(), any<() -> Unit>(), any<(Exception) -> Unit>()) - authenticationViewModel.logInWithEmail(userEmail = email, userPassword = password) + authenticationViewModel.logInWithEmail(userEmail = EMAIL, userPassword = PASSWORD) val result = when (authenticationViewModel.userAuthenticationState.value) { @@ -99,7 +99,7 @@ class AuthenticationViewModelTest { .`when`(authModel) .login(any(), any(), any<() -> Unit>(), any<(Exception) -> Unit>()) - authenticationViewModel.logInWithEmail(userEmail = email, userPassword = password) + authenticationViewModel.logInWithEmail(userEmail = EMAIL, userPassword = PASSWORD) val result = when (authenticationViewModel.userAuthenticationState.value) { @@ -177,8 +177,8 @@ class AuthenticationViewModelTest { @Test fun `loadAuthUserData success`() = runBlocking { - val userInfo: UserInfo = UserInfo(aud = aud, id = id, email = email) - val expected: AuthenticationUserData = AuthenticationUserData(uid = id, email = email) + val userInfo = UserInfo(aud = AUD, id = ID, email = EMAIL) + val expected = AuthenticationUserData(uid = ID, email = EMAIL) doAnswer { inv -> inv.getArgument<(UserInfo) -> Unit>(0)(userInfo) } .`when`(authModel) @@ -206,7 +206,7 @@ class AuthenticationViewModelTest { .`when`(authModel) .loginGoogle(any(), any(), any<() -> Unit>(), any<(Exception) -> Unit>()) - authenticationViewModel.loginWithGoogle(googleIdToken, rawNonce) + authenticationViewModel.loginWithGoogle(GOOGLE_ID_TOKEN, RAW_NONCE) val result = when (authenticationViewModel.userAuthenticationState.value) { @@ -225,7 +225,7 @@ class AuthenticationViewModelTest { .`when`(authModel) .loginGoogle(any(), any(), any<() -> Unit>(), any<(Exception) -> Unit>()) - authenticationViewModel.loginWithGoogle(googleIdToken, rawNonce) + authenticationViewModel.loginWithGoogle(GOOGLE_ID_TOKEN, RAW_NONCE) val result = when (authenticationViewModel.userAuthenticationState.value) { diff --git a/app/src/test/java/com/android/periodpals/model/chat/ChatViewModelTest.kt b/app/src/test/java/com/android/periodpals/model/chat/ChatViewModelTest.kt new file mode 100644 index 000000000..1919bfc1a --- /dev/null +++ b/app/src/test/java/com/android/periodpals/model/chat/ChatViewModelTest.kt @@ -0,0 +1,119 @@ +package com.android.periodpals.model.chat + +import android.annotation.SuppressLint +import android.util.Log +import androidx.compose.runtime.mutableStateOf +import com.android.periodpals.MainCoroutineRule +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.services.JwtTokenService +import io.getstream.chat.android.client.ChatClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatViewModelTest { + + @get:Rule val mainCoroutineRule: TestRule = MainCoroutineRule() + + private lateinit var chatViewModel: ChatViewModel + private lateinit var chatClient: ChatClient + private lateinit var authenticationViewModel: AuthenticationViewModel + + private val profile = + mutableStateOf( + User( + name = NAME, + imageUrl = IMAGE_URL, + description = DESCRIPTION, + dob = DOB, + preferredDistance = 1, + )) + private val authUserData = mutableStateOf(AuthenticationUserData(uid = UID, email = EMAIL)) + + companion object { + private const val SUCCESS_USER_CONNECTION_MESSAGE = "User connected successfully." + private const val FAIL_USER_CONNECTION_MESSAGE = + "Failed to connect user: profile or authentication data is null." + + private const val TAG = "ChatViewModel" + private const val UID = "uid" + private const val EMAIL = "email" + private const val NAME = "name" + private const val IMAGE_URL = "imageUrl" + private const val DESCRIPTION = "description" + private const val DOB = "31/01/1999" + } + + @Before + fun setUp() { + chatClient = mockk(relaxed = true) + authenticationViewModel = mockk(relaxed = true) + mockkObject(JwtTokenService) + chatViewModel = ChatViewModel(chatClient) + mockkStatic(Log::class) + + every { authenticationViewModel.authUserData } returns authUserData + } + + @Test + fun `connectUser should log error when authentication data is null`() = runTest { + every { authenticationViewModel.authUserData } returns mutableStateOf(null) + + var failureCalled = false + chatViewModel.connectUser( + profile.value, authenticationViewModel, onFailure = { failureCalled = true }) + + verify { Log.d(TAG, FAIL_USER_CONNECTION_MESSAGE) } + assert(failureCalled) + } + + @SuppressLint("CheckResult") + @Test + fun `connectUser should generate token and connect user successfully`() = runTest { + every { JwtTokenService.generateStreamToken(UID, any(), any()) } answers + { + secondArg<(String) -> Unit>().invoke("generated_token") + } + + var successCalled = false + chatViewModel.connectUser( + profile.value, authenticationViewModel, onSuccess = { successCalled = true }) + + val expectedUser = + io.getstream.chat.android.models.User(id = UID, name = NAME, image = IMAGE_URL) + verify { chatClient.connectUser(expectedUser, "generated_token") } + verify { Log.d(TAG, SUCCESS_USER_CONNECTION_MESSAGE) } + assert(successCalled) + } + + @Test + fun `connectUser should log error when token generation fails`() = runTest { + every { JwtTokenService.generateStreamToken(UID, any(), any()) } answers + { + thirdArg<(Exception) -> Unit>().invoke(Exception("Failed to generate token.")) + } + + var result: Exception? = null + chatViewModel.connectUser( + profile.value, + authenticationViewModel, + onSuccess = { fail("Should not call onSuccess") }, + onFailure = { result = it }) + + verify { Log.d(TAG, "Failed to generate token.") } + assertNotNull(result) + } +} 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 0e018d8c5..e0dce0249 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 @@ -84,7 +84,8 @@ class UserRepositorySupabaseTest { runTest { val userRepositorySupabase = UserRepositorySupabase(supabaseClientSuccess) - userRepositorySupabase.loadUserProfile({ result = it }, { fail("should not call onFailure") }) + userRepositorySupabase.loadUserProfile( + id, { result = it }, { fail("should not call onFailure") }) assertEquals(defaultUserDto, result) } } @@ -96,6 +97,35 @@ class UserRepositorySupabaseTest { runTest { val userRepositorySupabase = UserRepositorySupabase(supabaseClientFailure) userRepositorySupabase.loadUserProfile( + id, + { fail("should not call onSuccess") }, + { onFailureCalled = true }, + ) + assert(onFailureCalled) + } + } + + @Test + fun loadUserProfilesIsSuccessful() { + var result: List? = null + + runTest { + val userRepositorySupabase = UserRepositorySupabase(supabaseClientSuccess) + userRepositorySupabase.loadUserProfiles( + { result = it }, + { fail("should not call onFailure") }, + ) + assertEquals(listOf(defaultUserDto), result) + } + } + + @Test + fun loadUserProfilesHasFailed() { + var onFailureCalled = false + + runTest { + val userRepositorySupabase = UserRepositorySupabase(supabaseClientFailure) + userRepositorySupabase.loadUserProfiles( { fail("should not call onSuccess") }, { onFailureCalled = true }, ) 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 1f219b76e..a61ce0f44 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 @@ -69,7 +69,7 @@ class UserViewModelTest { } @Test - fun initHasSucceeded() = runTest { + fun loadUserIsSuccessful() = runTest { val user = UserDto( name, @@ -82,57 +82,28 @@ class UserViewModelTest { ) val expected = user.asUser() - doAnswer { it.getArgument<(UserDto) -> Unit>(0)(user) } - .`when`(userModel) - .loadUserProfile(any<(UserDto) -> Unit>(), any<(Exception) -> Unit>()) - - doAnswer { it.getArgument<(ByteArray) -> Unit>(1)(byteArrayOf(1)) } + doAnswer { it.getArgument<(UserDto) -> Unit>(1)(user) } .`when`(userModel) - .downloadFile(any(), any<(ByteArray) -> Unit>(), any<(Exception) -> Unit>()) + .loadUserProfile(any(), any<(UserDto) -> Unit>(), any<(Exception) -> Unit>()) - userViewModel.init() + userViewModel.loadUser(id) assertEquals(expected, userViewModel.user.value) } @Test - fun initLoadHasFailed() = runTest { - doAnswer { it.getArgument<(Exception) -> Unit>(1)(Exception("failed")) } - .`when`(userModel) - .loadUserProfile(any<(UserDto) -> Unit>(), any<(Exception) -> Unit>()) - - userViewModel.init() - - assertNull(userViewModel.user.value) - } - - @Test - fun initDownLoadHasFailed() = runTest { - val user = - UserDto( - name, - imageUrl, - description, - dob, - preferredDistance, - fcmToken, - locationGIS, - ) - doAnswer { it.getArgument<(UserDto) -> Unit>(0)(user) } - .`when`(userModel) - .loadUserProfile(any<(UserDto) -> Unit>(), any<(Exception) -> Unit>()) - + fun loadUserHasFailed() = runTest { doAnswer { it.getArgument<(Exception) -> Unit>(2)(Exception("failed")) } .`when`(userModel) - .downloadFile(any(), any<(ByteArray) -> Unit>(), any<(Exception) -> Unit>()) + .loadUserProfile(any(), any<(UserDto) -> Unit>(), any<(Exception) -> Unit>()) - userViewModel.downloadFile("test") + userViewModel.loadUser(id) assertNull(userViewModel.user.value) } @Test - fun loadUserIsSuccessful() = runTest { + fun loadUsersIsSuccessful() = runTest { val user = UserDto( name, @@ -145,39 +116,30 @@ class UserViewModelTest { ) val expected = user.asUser() - doAnswer { it.getArgument<(UserDto) -> Unit>(0)(user) } + doAnswer { it.getArgument<(List) -> Unit>(0)(listOf(user)) } .`when`(userModel) - .loadUserProfile(any<(UserDto) -> Unit>(), any<(Exception) -> Unit>()) + .loadUserProfiles(any<(List) -> Unit>(), any<(Exception) -> Unit>()) - userViewModel.loadUser() + userViewModel.loadUsers() - assertEquals(expected, userViewModel.user.value) + assertEquals(listOf(expected), userViewModel.users.value) } @Test - fun loadUserHasFailed() = runTest { + fun loadUsersHasFailed() = runTest { doAnswer { it.getArgument<(Exception) -> Unit>(1)(Exception("failed")) } .`when`(userModel) - .loadUserProfile(any<(UserDto) -> Unit>(), any<(Exception) -> Unit>()) + .loadUserProfiles(any<(List) -> Unit>(), any<(Exception) -> Unit>()) - userViewModel.loadUser() + userViewModel.loadUsers() - assertNull(userViewModel.user.value) + assertNull(userViewModel.users.value) } @Test fun saveUserIsSuccessful() = runTest { val expected = - UserDto( - name, - imageUrl, - description, - dob, - preferredDistance, - fcmToken, - locationGIS, - ) - .asUser() + UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken, locationGIS).asUser() doAnswer { it.getArgument<(UserDto) -> Unit>(1)(expected.asUserDto()) } .`when`(userModel) @@ -191,16 +153,7 @@ class UserViewModelTest { @Test fun saveUserHasFailed() = runTest { val test = - UserDto( - name, - imageUrl, - description, - dob, - preferredDistance, - fcmToken, - locationGIS, - ) - .asUser() + UserDto(name, imageUrl, description, dob, preferredDistance, fcmToken, locationGIS).asUser() doAnswer { it.getArgument<(Exception) -> Unit>(2)(Exception("failed")) } .`when`(userModel) @@ -311,7 +264,7 @@ class UserViewModelTest { @Test fun dobFieldHasCorrectValidators() { val dobField = userViewModel.formState.getState(UserViewModel.DOB_STATE_NAME) - assertEquals(2, dobField.validators.size) + assertEquals(3, dobField.validators.size) assert(dobField.validators.any { it is Validators.Required }) assert(dobField.validators.any { it is Validators.Custom }) } @@ -346,4 +299,29 @@ class UserViewModelTest { // invalid day assert(!validateDate("32/01/2000")) } + + @Test + fun isOldEnoughReturnsTrueForValidDate() { + assert(isOldEnough("01/01/2000")) + } + + @Test + fun isOldEnoughReturnsFalseForRecentDate() { + assert(!isOldEnough("01/01/2010")) + } + + @Test + fun isOldEnoughReturnsFalseForFutureDate() { + assert(!isOldEnough("01/01/2030")) + } + + @Test + fun isOldEnoughReturnsFalseForInvalidDate() { + assert(!isOldEnough("invalid_date")) + } + + @Test + fun isOldEnoughReturnsFalseForEmptyDate() { + assert(!isOldEnough("")) + } } 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 4ec3ce249..219d98a93 100644 --- a/app/src/test/java/com/android/periodpals/services/GPSServiceImplTest.kt +++ b/app/src/test/java/com/android/periodpals/services/GPSServiceImplTest.kt @@ -8,8 +8,10 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts 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.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 @@ -55,6 +57,7 @@ class GPSServiceImplTest { @Mock private lateinit var mockFusedLocationClient: FusedLocationProviderClient @Mock private lateinit var mockPermissionLauncher: ActivityResultLauncher> + private lateinit var authenticationViewModel: AuthenticationViewModel private lateinit var userViewModel: UserViewModel // Used to get the FusedLocationProviderClient @@ -82,6 +85,7 @@ class GPSServiceImplTest { mockActivity = mock(ComponentActivity::class.java) mockFusedLocationClient = mock(FusedLocationProviderClient::class.java) + authenticationViewModel = mock(AuthenticationViewModel::class.java) userViewModel = mock(UserViewModel::class.java) mockLocationServices = mockStatic(LocationServices::class.java) @@ -123,7 +127,7 @@ class GPSServiceImplTest { ) // Create instance of GPSServiceImpl... - gpsService = GPSServiceImpl(mockActivity, userViewModel) + gpsService = GPSServiceImpl(mockActivity, authenticationViewModel, userViewModel) // ... and verify that registerForActivityResult was called verify(mockActivity) @@ -131,6 +135,9 @@ class GPSServiceImplTest { any(), capture(permissionCallbackCaptor), ) + + `when`(authenticationViewModel.authUserData) + .thenReturn(mutableStateOf(AuthenticationUserData("test", "test"))) } @After @@ -360,8 +367,8 @@ class GPSServiceImplTest { val mockLong = 16.0 `when`(userViewModel.user).thenReturn(mutableStateOf(userNoLocation)) - `when`(userViewModel.loadUser(any(), any())).doAnswer { - val onSuccess = it.arguments[0] as () -> Unit + `when`(userViewModel.loadUser(any(), any(), any())).doAnswer { + val onSuccess = it.arguments[1] as () -> Unit onSuccess() } @@ -394,11 +401,11 @@ class GPSServiceImplTest { val mockLat = 42.0 val mockLong = 16.0 - val gpsService = GPSServiceImpl(mockActivity, userViewModel) + val gpsService = GPSServiceImpl(mockActivity, authenticationViewModel, userViewModel) `when`(userViewModel.user).thenReturn(mutableStateOf(userNoLocation)) - `when`(userViewModel.loadUser(any(), any())).doAnswer { - val onSuccess = it.arguments[0] as () -> Unit + `when`(userViewModel.loadUser(any(), any(), any())).doAnswer { + val onSuccess = it.arguments[1] as () -> Unit onSuccess() } diff --git a/app/src/test/java/com/android/periodpals/services/JwtTokenServiceTest.kt b/app/src/test/java/com/android/periodpals/services/JwtTokenServiceTest.kt new file mode 100644 index 000000000..3b6467ba2 --- /dev/null +++ b/app/src/test/java/com/android/periodpals/services/JwtTokenServiceTest.kt @@ -0,0 +1,72 @@ +package com.android.periodpals.services + +import com.android.periodpals.BuildConfig +import com.android.periodpals.model.timer.HOUR +import com.android.periodpals.model.timer.SECOND +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import kotlin.math.abs +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Test + +class JwtTokenServiceTest { + + companion object { + private const val FAILURE_MESSAGE = "java.lang.Exception: User ID is null or blank." + + private const val USER_ID = "test_user_id" + private const val MARGIN_OF_ERROR = 2L * SECOND + } + + @Test + fun generateStreamTokenSuccessful() { + var token: String? = null + JwtTokenService.generateStreamToken( + USER_ID, + onSuccess = { token = it }, + onFailure = { fail("Expected success but got failure: $it") }) + + assertNotNull(token) + + val decodedJWT = + JWT.require(Algorithm.HMAC256(BuildConfig.STREAM_SDK_SECRET)).build().verify(token) + + assertEquals(USER_ID, decodedJWT.getClaim("user_id").asString()) + + // Check if the expiration time is within the expected range + val currentTime = System.currentTimeMillis() + val expirationTime = decodedJWT.expiresAt.time + val expectedExpirationTime = currentTime + HOUR + + // Allow a small margin of error for the time difference + Assert.assertTrue(abs((expirationTime - expectedExpirationTime).toDouble()) <= MARGIN_OF_ERROR) + } + + @Test + fun generateStreamTokenEmptyUid() { + var failureMessage: String? = null + val emptyUserId = "" + JwtTokenService.generateStreamToken( + emptyUserId, + onSuccess = { fail("Should not call `onSuccess`") }, + onFailure = { failureMessage = it.toString() }) + + assertNotNull(failureMessage) + assertEquals(FAILURE_MESSAGE, failureMessage) + } + + @Test + fun generateStreamTokenNullUid() { + var failureMessage: String? = null + JwtTokenService.generateStreamToken( + null, + onSuccess = { fail("Should not call `onSuccess`") }, + onFailure = { failureMessage = it.toString() }) + + assertNotNull(failureMessage) + assertEquals(FAILURE_MESSAGE, failureMessage) + } +} diff --git a/app/src/test/java/com/android/periodpals/services/PushNotificationsServiceImplTest.kt b/app/src/test/java/com/android/periodpals/services/PushNotificationsServiceImplTest.kt index 867b4083f..f7072de13 100644 --- a/app/src/test/java/com/android/periodpals/services/PushNotificationsServiceImplTest.kt +++ b/app/src/test/java/com/android/periodpals/services/PushNotificationsServiceImplTest.kt @@ -1,6 +1,7 @@ package com.android.periodpals.services import android.Manifest +import android.annotation.SuppressLint import android.app.NotificationChannel import android.app.NotificationManager import android.content.pm.PackageManager @@ -12,6 +13,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.mutableStateOf import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat +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.google.android.gms.tasks.OnCompleteListener @@ -52,6 +55,7 @@ class PushNotificationsServiceImplTest { private lateinit var mockNotificationManagerCompat: NotificationManagerCompat private lateinit var mockRemoteMessage: RemoteMessage private lateinit var mockFirebaseMessaging: FirebaseMessaging + private lateinit var mockAuthenticationViewModel: AuthenticationViewModel private lateinit var mockUserViewModel: UserViewModel private lateinit var mockTokenTask: Task @@ -95,6 +99,7 @@ class PushNotificationsServiceImplTest { mockNotificationManagerCompat = mock(NotificationManagerCompat::class.java) mockRemoteMessage = mock(RemoteMessage::class.java) mockFirebaseMessaging = mock(FirebaseMessaging::class.java) + mockAuthenticationViewModel = mock(AuthenticationViewModel::class.java) mockUserViewModel = mock(UserViewModel::class.java) mockTokenTask = mock(Task::class.java) as Task @@ -125,7 +130,11 @@ class PushNotificationsServiceImplTest { } `when`(mockTokenTask.result).thenReturn(DEVICE_TOKEN) - pushNotificationsService = PushNotificationsServiceImpl(mockActivity, mockUserViewModel) + pushNotificationsService = + PushNotificationsServiceImpl(mockActivity, mockAuthenticationViewModel, mockUserViewModel) + + `when`(mockAuthenticationViewModel.authUserData) + .thenReturn(mutableStateOf(AuthenticationUserData("test", "test"))) } @After @@ -381,8 +390,8 @@ class PushNotificationsServiceImplTest { @Test fun `onNewToken loadUser success calls userVM saveUser`() { - `when`(mockUserViewModel.loadUser(any(), any())).thenAnswer { - val onSuccess = it.arguments[0] as () -> Unit + `when`(mockUserViewModel.loadUser(any(), any(), any())).thenAnswer { + val onSuccess = it.arguments[1] as () -> Unit onSuccess() } `when`(mockUserViewModel.user) @@ -410,8 +419,8 @@ class PushNotificationsServiceImplTest { @Test fun `onNewToken loadUser failure does not call userVM saveUser`() { - `when`(mockUserViewModel.loadUser(any(), any())).thenAnswer { - val onFailure = it.arguments[1] as (Exception) -> Unit + `when`(mockUserViewModel.loadUser(any(), any(), any())).thenAnswer { + val onFailure = it.arguments[2] as (Exception) -> Unit onFailure(Exception("test exception")) } @@ -420,10 +429,11 @@ class PushNotificationsServiceImplTest { verify(mockUserViewModel, never()).saveUser(any(), any(), any()) } + @SuppressLint("CheckResult") @Test fun `createDeviceToken token task success loadUser success calls userVM saveUser with correct attributes`() { - `when`(mockUserViewModel.loadUser(any(), any())).thenAnswer { - val onSuccess = it.arguments[0] as () -> Unit + `when`(mockUserViewModel.loadUser(any(), any(), any())).thenAnswer { + val onSuccess = it.arguments[1] as () -> Unit onSuccess() } `when`(mockUserViewModel.user) @@ -452,8 +462,8 @@ class PushNotificationsServiceImplTest { @Test fun `createDeviceToken token task success loadUser failure does not call userVM saveUser`() { - `when`(mockUserViewModel.loadUser(any(), any())).thenAnswer { - val onFailure = it.arguments[1] as (Exception) -> Unit + `when`(mockUserViewModel.loadUser(any(), any(), any())).thenAnswer { + val onFailure = it.arguments[2] as (Exception) -> Unit onFailure(Exception("test exception")) } `when`(mockTokenTask.isSuccessful).thenReturn(true) diff --git a/app/src/test/java/com/android/periodpals/ui/alert/AlertListsScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/alert/AlertListsScreenTest.kt index 493ca9228..1f77d3feb 100644 --- a/app/src/test/java/com/android/periodpals/ui/alert/AlertListsScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/alert/AlertListsScreenTest.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextInput +import com.android.periodpals.R import com.android.periodpals.model.alert.Alert import com.android.periodpals.model.alert.AlertViewModel import com.android.periodpals.model.alert.LIST_OF_PRODUCTS @@ -37,6 +38,7 @@ import com.android.periodpals.services.GPSServiceImpl import com.android.periodpals.ui.navigation.NavigationActions import com.android.periodpals.ui.navigation.Route import com.android.periodpals.ui.navigation.Screen +import io.github.kakaocup.kakao.common.utilities.getResourceString import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule @@ -69,8 +71,6 @@ class AlertListsScreenTest { private val authUserData = mutableStateOf(AuthenticationUserData(uid, email)) companion object { - private const val NO_MY_ALERTS_TEXT = "You haven't asked for help yet !" - private const val NO_PALS_ALERTS_TEXT = "No pal needs help yet !" private val MY_ALERTS_LIST: List = listOf( Alert( @@ -151,13 +151,19 @@ class AlertListsScreenTest { } composeTestRule.onNodeWithTag(AlertListsScreen.SCREEN).assertIsDisplayed() composeTestRule.onNodeWithTag(AlertListsScreen.TAB_ROW).assertIsDisplayed() - composeTestRule.onNodeWithTag(AlertListsScreen.MY_ALERTS_TAB).assertIsDisplayed() - composeTestRule.onNodeWithTag(AlertListsScreen.PALS_ALERTS_TAB).assertIsDisplayed() + composeTestRule + .onNodeWithTag(AlertListsScreen.MY_ALERTS_TAB) + .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.alert_lists_tab_my_alerts_title)) + composeTestRule + .onNodeWithTag(AlertListsScreen.PALS_ALERTS_TAB) + .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.alert_lists_tab_pals_alerts_title)) composeTestRule.onNodeWithTag(TopAppBar.TOP_BAR).assertIsDisplayed() composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals("Alert Lists") + .assertTextEquals(getResourceString(R.string.alert_lists_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsDisplayed() @@ -220,7 +226,7 @@ class AlertListsScreenTest { .onNodeWithTag(AlertListsScreen.NO_ALERTS_TEXT) .performScrollTo() .assertIsDisplayed() - .assertTextEquals(NO_MY_ALERTS_TEXT) + .assertTextEquals(getResourceString(R.string.alert_lists_no_my_alerts_dialog)) } @Test @@ -326,7 +332,7 @@ class AlertListsScreenTest { .onNodeWithTag(AlertListsScreen.NO_ALERTS_TEXT) .performScrollTo() .assertIsDisplayed() - .assertTextEquals(NO_PALS_ALERTS_TEXT) + .assertTextEquals(getResourceString(R.string.alert_lists_no_pals_alerts_dialog)) } @Test diff --git a/app/src/test/java/com/android/periodpals/ui/alert/CreateAlertScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/alert/CreateAlertScreenTest.kt index fd85cb8bc..2853e3abd 100644 --- a/app/src/test/java/com/android/periodpals/ui/alert/CreateAlertScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/alert/CreateAlertScreenTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import com.android.periodpals.R import com.android.periodpals.model.alert.AlertViewModel import com.android.periodpals.model.alert.AlertViewModel.Companion.LOCATION_STATE_NAME import com.android.periodpals.model.alert.AlertViewModel.Companion.MESSAGE_STATE_NAME @@ -40,6 +41,7 @@ import com.android.periodpals.ui.navigation.TopLevelDestination import com.dsc.form_builder.FormState import com.dsc.form_builder.TextFieldState import com.dsc.form_builder.Validators +import io.github.kakaocup.kakao.common.utilities.getResourceString import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals import org.junit.Before @@ -74,7 +76,6 @@ class CreateAlertScreenTest { private val LOCATION_SUGGESTION2 = Location(46.2017559, 6.1466014, "Geneva, Switzerland") private val LOCATION_SUGGESTION3 = Location(46.1683026, 5.9059776, "Farges, Gex, Ain") private const val MESSAGE = "I need help finding a tampon" - private const val SUBMIT_BUTTON_TEXT = "Ask for Help" private const val NUM_ITEMS_WHEN_SUGGESTION = 4 private const val NUM_ITEMS_WHEN_NO_SUGGESTION = 1 @@ -95,13 +96,19 @@ class CreateAlertScreenTest { listOf( Validators.Custom( message = ERROR_INVALID_PRODUCT, - function = { it.toString() != PRODUCT_DROPDOWN_DEFAULT_VALUE }, + function = { + it.toString() != + getResourceString(R.string.create_alert_product_dropdown_default_value) + }, )) private val urgencyValidators = listOf( Validators.Custom( message = ERROR_INVALID_URGENCY, - function = { it.toString() != URGENCY_DROPDOWN_DEFAULT_VALUE }, + function = { + it.toString() != + getResourceString(R.string.create_alert_urgency_dropdown_default_value) + }, )) private val locationValidators = listOf( @@ -187,7 +194,7 @@ class CreateAlertScreenTest { composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals("Create Alert") + .assertTextEquals(getResourceString(R.string.create_alert_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsNotDisplayed() @@ -198,6 +205,7 @@ class CreateAlertScreenTest { .onNodeWithTag(AlertInputs.INSTRUCTION_TEXT) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.create_alert_instruction_text)) composeTestRule.onNodeWithTag(AlertInputs.PRODUCT_FIELD).performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag(AlertInputs.URGENCY_FIELD).performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag(AlertInputs.LOCATION_FIELD).performScrollTo().assertIsDisplayed() @@ -206,7 +214,7 @@ class CreateAlertScreenTest { .onNodeWithTag(C.Tag.CreateAlertScreen.SUBMIT_BUTTON) .performScrollTo() .assertIsDisplayed() - .assertTextEquals(SUBMIT_BUTTON_TEXT) + .assertTextEquals(getResourceString(R.string.create_alert_submission_button_text)) } @Test diff --git a/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt index 3247d282f..70214452a 100644 --- a/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import com.android.periodpals.R import com.android.periodpals.model.alert.Alert import com.android.periodpals.model.alert.AlertViewModel import com.android.periodpals.model.alert.AlertViewModel.Companion.LOCATION_STATE_NAME @@ -40,6 +41,7 @@ import com.android.periodpals.ui.navigation.TopLevelDestination import com.dsc.form_builder.FormState import com.dsc.form_builder.TextFieldState import com.dsc.form_builder.Validators +import io.github.kakaocup.kakao.common.utilities.getResourceString import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule @@ -73,9 +75,6 @@ class EditAlertScreenTest { private val LOCATION_SUGGESTION2 = Location(46.2017559, 6.1466014, "Geneva, Switzerland") private val LOCATION_SUGGESTION3 = Location(46.1683026, 5.9059776, "Farges, Gex, Ain") private const val MESSAGE = "I need help finding a tampon" - private const val DELETE_BUTTON_TEXT = "Delete" - private const val SAVE_BUTTON_TEXT = "Save" - private const val RESOLVE_BUTTON_TEXT = "Resolve" private const val MAX_LOCATION_LENGTH = 512 private const val MAX_MESSAGE_LENGTH = 512 @@ -93,13 +92,19 @@ class EditAlertScreenTest { listOf( Validators.Custom( message = ERROR_INVALID_PRODUCT, - function = { it.toString() != PRODUCT_DROPDOWN_DEFAULT_VALUE }, + function = { + it.toString() != + getResourceString(R.string.create_alert_product_dropdown_default_value) + }, )) private val urgencyValidators = listOf( Validators.Custom( message = ERROR_INVALID_URGENCY, - function = { it.toString() != URGENCY_DROPDOWN_DEFAULT_VALUE }, + function = { + it.toString() != + getResourceString(R.string.create_alert_urgency_dropdown_default_value) + }, )) private val locationValidators = listOf( @@ -192,7 +197,7 @@ class EditAlertScreenTest { composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals("Edit Your Alert") + .assertTextEquals(getResourceString(R.string.edit_alert_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsNotDisplayed() @@ -205,6 +210,7 @@ class EditAlertScreenTest { .onNodeWithTag(AlertInputs.INSTRUCTION_TEXT) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.edit_alert_instruction_text)) composeTestRule.onNodeWithTag(AlertInputs.PRODUCT_FIELD).performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag(AlertInputs.URGENCY_FIELD).performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag(AlertInputs.LOCATION_FIELD).performScrollTo().assertIsDisplayed() @@ -213,17 +219,17 @@ class EditAlertScreenTest { .onNodeWithTag(EditAlertScreen.DELETE_BUTTON) .performScrollTo() .assertIsDisplayed() - .assertTextEquals(DELETE_BUTTON_TEXT) + .assertTextEquals(getResourceString(R.string.edit_alert_delete_button_text)) composeTestRule .onNodeWithTag(EditAlertScreen.SAVE_BUTTON) .performScrollTo() .assertIsDisplayed() - .assertTextEquals(SAVE_BUTTON_TEXT) + .assertTextEquals(getResourceString(R.string.edit_alert_save_button_text)) composeTestRule .onNodeWithTag(EditAlertScreen.RESOLVE_BUTTON) .performScrollTo() .assertIsDisplayed() - .assertTextEquals(RESOLVE_BUTTON_TEXT) + .assertTextEquals(getResourceString(R.string.edit_alert_resolve_button_text)) } @Test diff --git a/app/src/test/java/com/android/periodpals/ui/authentication/SignInTest.kt b/app/src/test/java/com/android/periodpals/ui/authentication/SignInTest.kt index 67e492e22..3398a0b86 100644 --- a/app/src/test/java/com/android/periodpals/ui/authentication/SignInTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/authentication/SignInTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import com.android.periodpals.R import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.authentication.AuthenticationViewModel.Companion.EMAIL_STATE_NAME import com.android.periodpals.model.authentication.AuthenticationViewModel.Companion.PASSWORD_LOGIN_STATE_NAME @@ -21,6 +22,7 @@ import com.android.periodpals.ui.navigation.Screen import com.dsc.form_builder.FormState import com.dsc.form_builder.TextFieldState import com.dsc.form_builder.Validators +import io.github.kakaocup.kakao.common.utilities.getResourceString import org.junit.Before import org.junit.Rule import org.junit.Test @@ -111,6 +113,7 @@ class SignInScreenTest { .onNodeWithTag(SignInScreen.INSTRUCTION_TEXT) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_in_instruction)) composeTestRule .onNodeWithTag(AuthenticationScreens.EMAIL_FIELD) .performScrollTo() @@ -123,16 +126,26 @@ class SignInScreenTest { .onNodeWithTag(AuthenticationScreens.PASSWORD_VISIBILITY_BUTTON) .performScrollTo() .assertIsDisplayed() - composeTestRule.onNodeWithTag(SignInScreen.SIGN_IN_BUTTON).performScrollTo().assertIsDisplayed() + composeTestRule + .onNodeWithTag(SignInScreen.SIGN_IN_BUTTON) + .performScrollTo() + .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_in_button_text)) composeTestRule .onNodeWithTag(SignInScreen.CONTINUE_WITH_TEXT) .performScrollTo() .assertIsDisplayed() - composeTestRule.onNodeWithTag(SignInScreen.GOOGLE_BUTTON).performScrollTo().assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_in_continue_with_text)) + composeTestRule + .onNodeWithTag(SignInScreen.GOOGLE_BUTTON) + .performScrollTo() + .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_in_sign_up_with_google)) composeTestRule .onNodeWithTag(SignInScreen.NOT_REGISTERED_NAV_LINK) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_in_sign_up_text)) } @Test diff --git a/app/src/test/java/com/android/periodpals/ui/authentication/SignUpTest.kt b/app/src/test/java/com/android/periodpals/ui/authentication/SignUpTest.kt index 4e2250625..46dad09e1 100644 --- a/app/src/test/java/com/android/periodpals/ui/authentication/SignUpTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/authentication/SignUpTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import com.android.periodpals.R import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.authentication.AuthenticationViewModel.Companion.CONFIRM_PASSWORD_STATE_NAME import com.android.periodpals.model.authentication.AuthenticationViewModel.Companion.EMAIL_STATE_NAME @@ -22,6 +23,7 @@ import com.android.periodpals.ui.navigation.Screen import com.dsc.form_builder.FormState import com.dsc.form_builder.TextFieldState import com.dsc.form_builder.Validators +import io.github.kakaocup.kakao.common.utilities.getResourceString import org.junit.Before import org.junit.Rule import org.junit.Test @@ -148,6 +150,7 @@ class SignUpScreenTest { .onNodeWithTag(SignUpScreen.INSTRUCTION_TEXT) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_up_instruction)) composeTestRule .onNodeWithTag(AuthenticationScreens.EMAIL_FIELD) .performScrollTo() @@ -164,6 +167,7 @@ class SignUpScreenTest { .onNodeWithTag(SignUpScreen.CONFIRM_PASSWORD_TEXT) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_up_confirm_password_instruction)) composeTestRule .onNodeWithTag(SignUpScreen.CONFIRM_PASSWORD_FIELD) .performScrollTo() @@ -172,11 +176,16 @@ class SignUpScreenTest { .onNodeWithTag(SignUpScreen.CONFIRM_PASSWORD_VISIBILITY_BUTTON) .performScrollTo() .assertIsDisplayed() - composeTestRule.onNodeWithTag(SignUpScreen.SIGN_UP_BUTTON).performScrollTo().assertIsDisplayed() + composeTestRule + .onNodeWithTag(SignUpScreen.SIGN_UP_BUTTON) + .performScrollTo() + .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_up_button_text)) composeTestRule .onNodeWithTag(SignUpScreen.ALREADY_REGISTERED_NAV_LINK) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.sign_up_sign_in_text)) } @Test @@ -304,7 +313,7 @@ class SignUpScreenTest { .assertIsDisplayed() composeTestRule .onNodeWithTag(SignUpScreen.CONFIRM_PASSWORD_ERROR_TEXT) - .assertTextEquals("Passwords do not match") + .assertTextEquals(getResourceString(R.string.sign_up_not_matching_password_error_message)) } @Test diff --git a/app/src/test/java/com/android/periodpals/ui/map/MapScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/map/MapScreenTest.kt index 3fa8bd27f..f35360bdc 100644 --- a/app/src/test/java/com/android/periodpals/ui/map/MapScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/map/MapScreenTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextInput +import com.android.periodpals.R import com.android.periodpals.model.alert.Alert import com.android.periodpals.model.alert.AlertViewModel import com.android.periodpals.model.alert.LIST_OF_PRODUCTS @@ -31,6 +32,7 @@ import com.android.periodpals.resources.C.Tag.TopAppBar import com.android.periodpals.services.GPSServiceImpl import com.android.periodpals.ui.navigation.NavigationActions import com.android.periodpals.ui.navigation.Screen +import io.github.kakaocup.kakao.common.utilities.getResourceString import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule @@ -44,7 +46,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner -private const val MAP_SCREEN_TITLE = "Map" private const val MOCK_ACCURACY = 15.0f private const val LOCATION = "Bern" @@ -136,7 +137,7 @@ class MapScreenTest { composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals(MAP_SCREEN_TITLE) + .assertTextEquals(getResourceString(R.string.map_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsNotDisplayed() diff --git a/app/src/test/java/com/android/periodpals/ui/profile/CreateProfileTest.kt b/app/src/test/java/com/android/periodpals/ui/profile/CreateProfileTest.kt index 9b172ab83..7f3c713f7 100644 --- a/app/src/test/java/com/android/periodpals/ui/profile/CreateProfileTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/profile/CreateProfileTest.kt @@ -10,12 +10,15 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import com.android.periodpals.R +import com.android.periodpals.model.user.MIN_AGE import com.android.periodpals.model.user.User import com.android.periodpals.model.user.UserViewModel import com.android.periodpals.model.user.UserViewModel.Companion.DESCRIPTION_STATE_NAME import com.android.periodpals.model.user.UserViewModel.Companion.DOB_STATE_NAME import com.android.periodpals.model.user.UserViewModel.Companion.NAME_STATE_NAME import com.android.periodpals.model.user.UserViewModel.Companion.PROFILE_IMAGE_STATE_NAME +import com.android.periodpals.model.user.isOldEnough import com.android.periodpals.model.user.validateDate import com.android.periodpals.resources.C.Tag.AlertListsScreen import com.android.periodpals.resources.C.Tag.BottomNavigationMenu @@ -28,6 +31,9 @@ import com.android.periodpals.ui.navigation.TopLevelDestination import com.dsc.form_builder.FormState import com.dsc.form_builder.TextFieldState import com.dsc.form_builder.Validators +import io.github.kakaocup.kakao.common.utilities.getResourceString +import java.time.LocalDate +import java.time.format.DateTimeFormatter import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue import org.junit.Before @@ -61,7 +67,8 @@ class CreateProfileTest { imageUrl = imageUrl, description = description, dob = dob, - preferredDistance = preferredDistance)) + preferredDistance = preferredDistance, + )) private const val MAX_NAME_LENGTH = 128 private const val MAX_DESCRIPTION_LENGTH = 512 @@ -71,7 +78,8 @@ class CreateProfileTest { private const val ERROR_INVALID_DESCRIPTION = "Please enter a description" private const val ERROR_DESCRIPTION_TOO_LONG = "Description must be less than $MAX_DESCRIPTION_LENGTH characters" - private const val ERROR_INVALID_DOB = "Invalid date" + private const val ERROR_INVALID_DOB = "Please enter a valid date" + private const val ERROR_TOO_YOUNG = "You must be at least $MIN_AGE years old" private val nameValidators = listOf( @@ -88,6 +96,7 @@ class CreateProfileTest { Validators.Required(message = ERROR_INVALID_DOB), Validators.Custom( message = ERROR_INVALID_DOB, function = { validateDate(it as String) }), + Validators.Custom(message = ERROR_TOO_YOUNG, function = { isOldEnough(it as String) }), ) private val profileImageValidators = emptyList() } @@ -123,7 +132,7 @@ class CreateProfileTest { composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals("Create Your Account") + .assertTextEquals(getResourceString(R.string.create_profile_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsNotDisplayed() @@ -146,6 +155,10 @@ class CreateProfileTest { .onNodeWithTag(ProfileScreens.DOB_INPUT_FIELD) .performScrollTo() .assertIsDisplayed() + composeTestRule + .onNodeWithTag(ProfileScreens.DOB_MIN_AGE_TEXT) + .performScrollTo() + .assertIsDisplayed() composeTestRule .onNodeWithTag(ProfileScreens.YOUR_PROFILE_SECTION) .performScrollTo() @@ -158,6 +171,7 @@ class CreateProfileTest { .onNodeWithTag(CreateProfileScreen.FILTER_RADIUS_EXPLANATION_TEXT) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.create_profile_radius_explanation_text)) composeTestRule .onNodeWithTag(AlertListsScreen.FILTER_RADIUS_TEXT) .performScrollTo() @@ -224,6 +238,33 @@ class CreateProfileTest { verify(navigationActions, never()).navigateTo(any()) } + @Test + fun createInvalidProfileTooYoung() { + `when`(userViewModel.user).thenReturn(userState) + composeTestRule.setContent { CreateProfileScreen(userViewModel, navigationActions) } + + val tooYoungDate = LocalDate.now().minusYears(MIN_AGE).plusDays(1) + + composeTestRule + .onNodeWithTag(ProfileScreens.DOB_INPUT_FIELD) + .performScrollTo() + .performTextInput(tooYoungDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))) + composeTestRule + .onNodeWithTag(ProfileScreens.NAME_INPUT_FIELD) + .performScrollTo() + .performTextInput(name) + composeTestRule + .onNodeWithTag(ProfileScreens.DESCRIPTION_INPUT_FIELD) + .performScrollTo() + .performTextInput(description) + composeTestRule.onNodeWithTag(ProfileScreens.SAVE_BUTTON).performScrollTo().performClick() + + verify(userViewModel, never()).saveUser(any(), any(), any()) + + verify(navigationActions, never()).navigateTo(any()) + verify(navigationActions, never()).navigateTo(any()) + } + @Test fun createInvalidProfileNoDob() { `when`(userViewModel.user).thenReturn(userState) @@ -266,30 +307,6 @@ class CreateProfileTest { verify(navigationActions, never()).navigateTo(any()) } - @Test - fun initVmSuccess() { - `when`(userViewModel.user).thenReturn(userState) - `when`(userViewModel.init()) - .thenAnswer({ - val onSuccess = it.arguments[0] as () -> Unit - onSuccess() - }) - composeTestRule.setContent { EditProfileScreen(userViewModel, navigationActions) } - verify(navigationActions, never()).navigateTo(Screen.PROFILE) - } - - @Test - fun initVmFailure() { - `when`(userViewModel.user).thenReturn(userState) - `when`(userViewModel.init()) - .thenAnswer({ - val onFailure = it.arguments[1] as () -> Unit - onFailure() - }) - composeTestRule.setContent { EditProfileScreen(userViewModel, navigationActions) } - verify(navigationActions, never()).navigateTo(Screen.PROFILE) - } - @Test fun createValidProfileVMFailure() { `when`(userViewModel.user).thenReturn(mutableStateOf(null)) diff --git a/app/src/test/java/com/android/periodpals/ui/profile/EditProfileTest.kt b/app/src/test/java/com/android/periodpals/ui/profile/EditProfileTest.kt index 95c32279d..5fd1f3e64 100644 --- a/app/src/test/java/com/android/periodpals/ui/profile/EditProfileTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/profile/EditProfileTest.kt @@ -11,12 +11,15 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import com.android.periodpals.R +import com.android.periodpals.model.user.MIN_AGE import com.android.periodpals.model.user.User import com.android.periodpals.model.user.UserViewModel import com.android.periodpals.model.user.UserViewModel.Companion.DESCRIPTION_STATE_NAME import com.android.periodpals.model.user.UserViewModel.Companion.DOB_STATE_NAME import com.android.periodpals.model.user.UserViewModel.Companion.NAME_STATE_NAME import com.android.periodpals.model.user.UserViewModel.Companion.PROFILE_IMAGE_STATE_NAME +import com.android.periodpals.model.user.isOldEnough import com.android.periodpals.model.user.validateDate import com.android.periodpals.resources.C.Tag.BottomNavigationMenu import com.android.periodpals.resources.C.Tag.ProfileScreens @@ -28,6 +31,9 @@ import com.android.periodpals.ui.navigation.Screen import com.dsc.form_builder.FormState import com.dsc.form_builder.TextFieldState import com.dsc.form_builder.Validators +import io.github.kakaocup.kakao.common.utilities.getResourceString +import java.time.LocalDate +import java.time.format.DateTimeFormatter import org.junit.Before import org.junit.Rule import org.junit.Test @@ -60,7 +66,8 @@ class EditProfileTest { imageUrl = imageUrl, description = description, dob = dob, - preferredDistance = preferredDistance)) + preferredDistance = preferredDistance, + )) private const val MAX_NAME_LENGTH = 128 private const val MAX_DESCRIPTION_LENGTH = 512 @@ -70,7 +77,8 @@ class EditProfileTest { private const val ERROR_INVALID_DESCRIPTION = "Please enter a description" private const val ERROR_DESCRIPTION_TOO_LONG = "Description must be less than $MAX_DESCRIPTION_LENGTH characters" - private const val ERROR_INVALID_DOB = "Invalid date" + private const val ERROR_INVALID_DOB = "Please enter a valid date" + private const val ERROR_TOO_YOUNG = "You must be at least $MIN_AGE years old" private val nameValidators = listOf( @@ -87,6 +95,7 @@ class EditProfileTest { Validators.Required(message = ERROR_INVALID_DOB), Validators.Custom( message = ERROR_INVALID_DOB, function = { validateDate(it as String) }), + Validators.Custom(message = ERROR_TOO_YOUNG, function = { isOldEnough(it as String) }), ) private val profileImageValidators = emptyList() } @@ -121,7 +130,7 @@ class EditProfileTest { composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals("Edit Your Profile") + .assertTextEquals(getResourceString(R.string.edit_profile_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsNotDisplayed() @@ -148,6 +157,10 @@ class EditProfileTest { .onNodeWithTag(ProfileScreens.DOB_INPUT_FIELD) .performScrollTo() .assertIsDisplayed() + composeTestRule + .onNodeWithTag(ProfileScreens.DOB_MIN_AGE_TEXT) + .performScrollTo() + .assertIsDisplayed() composeTestRule .onNodeWithTag(ProfileScreens.YOUR_PROFILE_SECTION) .performScrollTo() @@ -293,6 +306,42 @@ class EditProfileTest { verify(navigationActions, never()).navigateTo(any()) } + @Test + fun editProfileDOBTooYoung() { + `when`(userViewModel.user).thenReturn(userState) + composeTestRule.setContent { EditProfileScreen(userViewModel, navigationActions) } + + val tooYoungDate = LocalDate.now().minusYears(MIN_AGE).plusDays(1) + + composeTestRule + .onNodeWithTag(ProfileScreens.NAME_INPUT_FIELD) + .performScrollTo() + .performTextClearance() + composeTestRule + .onNodeWithTag(ProfileScreens.DOB_INPUT_FIELD) + .performScrollTo() + .performTextClearance() + composeTestRule + .onNodeWithTag(ProfileScreens.DESCRIPTION_INPUT_FIELD) + .performScrollTo() + .performTextClearance() + composeTestRule + .onNodeWithTag(ProfileScreens.NAME_INPUT_FIELD) + .performScrollTo() + .performTextInput(name) + composeTestRule + .onNodeWithTag(ProfileScreens.DOB_INPUT_FIELD) + .performScrollTo() + .performTextInput(tooYoungDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))) + composeTestRule + .onNodeWithTag(ProfileScreens.DESCRIPTION_INPUT_FIELD) + .performScrollTo() + .performTextInput(description) + composeTestRule.onNodeWithTag(ProfileScreens.SAVE_BUTTON).performScrollTo().performClick() + + verify(navigationActions, never()).navigateTo(any()) + } + @Test fun editInvalidProfileNoDescription() { `when`(userViewModel.user).thenReturn(userState) @@ -358,14 +407,26 @@ class EditProfileTest { } composeTestRule.setContent { EditProfileScreen(userViewModel, navigationActions) } + composeTestRule + .onNodeWithTag(ProfileScreens.DOB_INPUT_FIELD) + .performScrollTo() + .performTextClearance() composeTestRule .onNodeWithTag(ProfileScreens.DOB_INPUT_FIELD) .performScrollTo() .performTextInput(dob) + composeTestRule + .onNodeWithTag(ProfileScreens.NAME_INPUT_FIELD) + .performScrollTo() + .performTextClearance() composeTestRule .onNodeWithTag(ProfileScreens.NAME_INPUT_FIELD) .performScrollTo() .performTextInput(name) + composeTestRule + .onNodeWithTag(ProfileScreens.DESCRIPTION_INPUT_FIELD) + .performScrollTo() + .performTextClearance() composeTestRule .onNodeWithTag(ProfileScreens.DESCRIPTION_INPUT_FIELD) .performScrollTo() diff --git a/app/src/test/java/com/android/periodpals/ui/profile/ProfileScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/profile/ProfileScreenTest.kt index 43a3a7d3f..bd30cc913 100644 --- a/app/src/test/java/com/android/periodpals/ui/profile/ProfileScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/profile/ProfileScreenTest.kt @@ -8,6 +8,10 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import com.android.periodpals.R +import com.android.periodpals.model.authentication.AuthenticationViewModel +import com.android.periodpals.model.chat.ChatViewModel +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 @@ -18,56 +22,72 @@ import com.android.periodpals.services.PushNotificationsService import com.android.periodpals.ui.navigation.NavigationActions import com.android.periodpals.ui.navigation.Route import com.android.periodpals.ui.navigation.Screen +import io.github.kakaocup.kakao.common.utilities.getResourceString import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock import org.mockito.Mockito -import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.never import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class ProfileScreenTest { - private lateinit var navigationActions: NavigationActions - private lateinit var userViewModel: UserViewModel - private lateinit var pushNotificationsService: PushNotificationsService + @Mock private lateinit var userViewModel: UserViewModel + @Mock private lateinit var authenticationViewModel: AuthenticationViewModel + @Mock private lateinit var navigationActions: NavigationActions + @Mock private lateinit var pushNotificationsService: PushNotificationsService + @Mock private lateinit var chatViewModel: ChatViewModel @get:Rule val composeTestRule = createComposeRule() companion object { - 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 const val NAME = "John Doe" + private const val IMAGE_URL = "https://example.com" + private const val DESCRIPTION = "A short description" + private const val DOB = "01/01/2000" + private const val PREFERRED_DISTANCE = 500 private val userState = mutableStateOf( User( - name = name, - imageUrl = imageUrl, - description = description, - dob = dob, - preferredDistance = preferredDistance)) + name = NAME, + imageUrl = IMAGE_URL, + description = DESCRIPTION, + dob = DOB, + preferredDistance = PREFERRED_DISTANCE)) private val userAvatar = mutableStateOf(byteArrayOf()) } @Before fun setUp() { - navigationActions = mock(NavigationActions::class.java) - userViewModel = mock(UserViewModel::class.java) - pushNotificationsService = mock(PushNotificationsService::class.java) + MockitoAnnotations.openMocks(this) `when`(navigationActions.currentRoute()).thenReturn(Route.PROFILE) + `when`(userViewModel.user).thenReturn(userState) + `when`(userViewModel.avatar).thenReturn(userAvatar) + `when`(authenticationViewModel.authUserData) + .thenReturn(mutableStateOf(AuthenticationUserData("test", "test"))) + `when`(userViewModel.loadUser(any(), any(), any())).thenAnswer { + val onSuccess = it.arguments[1] as () -> Unit + onSuccess() + } } @Test fun allComponentsAreDisplayed() { - `when`(userViewModel.user).thenReturn(userState) - `when`(userViewModel.avatar).thenReturn(userAvatar) composeTestRule.setContent { - ProfileScreen(userViewModel, pushNotificationsService, navigationActions) + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions) } composeTestRule.onNodeWithTag(ProfileScreen.SCREEN).assertIsDisplayed() @@ -75,7 +95,7 @@ class ProfileScreenTest { composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals("Your Profile") + .assertTextEquals(getResourceString(R.string.profile_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsNotDisplayed() @@ -99,7 +119,7 @@ class ProfileScreenTest { .onNodeWithTag(ProfileScreen.REVIEWS_SECTION) .performScrollTo() .assertIsDisplayed() - .assertTextEquals("Reviews") + .assertTextEquals(getResourceString(R.string.profile_reviews_title)) composeTestRule .onNodeWithTag(ProfileScreen.NO_REVIEWS_ICON) .performScrollTo() @@ -108,6 +128,7 @@ class ProfileScreenTest { .onNodeWithTag(ProfileScreen.NO_REVIEWS_TEXT) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.profile_no_reviews_text)) composeTestRule .onNodeWithTag(ProfileScreen.NO_REVIEWS_CARD) .performScrollTo() @@ -116,10 +137,13 @@ class ProfileScreenTest { @Test fun settingsButtonNavigatesToSettingsScreen() { - `when`(userViewModel.user).thenReturn(userState) - `when`(userViewModel.avatar).thenReturn(userAvatar) composeTestRule.setContent { - ProfileScreen(userViewModel, pushNotificationsService, navigationActions) + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions) } composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).performClick() @@ -129,11 +153,13 @@ class ProfileScreenTest { @Test fun editButtonNavigatesToEditProfileScreen() { - `when`(userViewModel.user).thenReturn(userState) - `when`(userViewModel.avatar).thenReturn(userAvatar) - composeTestRule.setContent { - ProfileScreen(userViewModel, pushNotificationsService, navigationActions) + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions) } composeTestRule.onNodeWithTag(TopAppBar.EDIT_BUTTON).performClick() @@ -143,67 +169,180 @@ class ProfileScreenTest { @Test fun initVmSuccess() { - `when`(userViewModel.user).thenReturn(userState) - `when`(userViewModel.avatar).thenReturn(userAvatar) - - `when`(userViewModel.init()) + `when`( + init( + userViewModel, + authenticationViewModel, + chatViewModel, + userViewModel.user.value, + {}, + {}, + )) .thenAnswer({ - val onSuccess = it.arguments[0] as () -> Unit + val onSuccess = it.arguments[2] as () -> Unit onSuccess() }) composeTestRule.setContent { - ProfileScreen(userViewModel, pushNotificationsService, navigationActions) + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions) } org.mockito.kotlin.verify(navigationActions, Mockito.never()).navigateTo(Screen.PROFILE) } @Test fun initVmFailure() { - `when`(userViewModel.user).thenReturn(userState) - `when`(userViewModel.avatar).thenReturn(userAvatar) - - `when`(userViewModel.init()) + `when`( + init( + userViewModel, + authenticationViewModel, + chatViewModel, + userViewModel.user.value, + {}, + {}, + )) .thenAnswer({ val onFailure = it.arguments[1] as () -> Unit onFailure() }) composeTestRule.setContent { - ProfileScreen(userViewModel, pushNotificationsService, navigationActions) + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions) } org.mockito.kotlin.verify(navigationActions, Mockito.never()).navigateTo(Screen.PROFILE) } @Test fun profileScreenHasCorrectContentVMSuccess() { - `when`(userViewModel.user).thenReturn(userState) - `when`(userViewModel.avatar).thenReturn(userAvatar) - composeTestRule.setContent { - ProfileScreen(userViewModel, pushNotificationsService, navigationActions) + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions) } - composeTestRule.onNodeWithTag(ProfileScreen.NAME_FIELD).performScrollTo().assertTextEquals(name) + composeTestRule.onNodeWithTag(ProfileScreen.NAME_FIELD).performScrollTo().assertTextEquals(NAME) composeTestRule .onNodeWithTag(ProfileScreen.DESCRIPTION_FIELD) .performScrollTo() - .assertTextEquals(description) + .assertTextEquals(DESCRIPTION) } @Test fun profileScreenHasCorrectContentVMFailure() { `when`(userViewModel.user).thenReturn(mutableStateOf(null)) - `when`(userViewModel.avatar).thenReturn(userAvatar) composeTestRule.setContent { - ProfileScreen(userViewModel, pushNotificationsService, navigationActions) + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions) } composeTestRule .onNodeWithTag(ProfileScreen.NAME_FIELD) .performScrollTo() - .assertTextEquals("Error loading name, try again later.") + .assertTextEquals(getResourceString(R.string.profile_default_name)) composeTestRule .onNodeWithTag(ProfileScreen.DESCRIPTION_FIELD) .performScrollTo() - .assertTextEquals("Error loading description, try again later.") + .assertTextEquals(getResourceString(R.string.profile_default_description)) + } + + @Test + fun loadAndConnectClient() { + doAnswer { invocation -> + val onSuccess = invocation.arguments[0] as () -> Unit + onSuccess() + } + .`when`(authenticationViewModel) + .loadAuthenticationUserData(any(), any()) + doAnswer { invocation -> + val onSuccess = invocation.arguments[2] as () -> Unit + onSuccess() + } + .`when`(chatViewModel) + .connectUser(any(), any(), any(), any()) + + composeTestRule.setContent { + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions, + ) + } + + verify(authenticationViewModel).loadAuthenticationUserData(any(), any()) + verify(chatViewModel).connectUser(any(), any(), any(), any()) + } + + @Test + fun loadFailsCannotConnectClient() { + doAnswer { invocation -> + val onFailure = invocation.arguments[1] as (Exception) -> Unit + onFailure(RuntimeException("Failed to load user data")) + } + .`when`(authenticationViewModel) + .loadAuthenticationUserData(any(), any()) + doAnswer { invocation -> + val onSuccess = invocation.arguments[2] as () -> Unit + onSuccess() + } + .`when`(chatViewModel) + .connectUser(any(), any(), any(), any()) + + composeTestRule.setContent { + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions, + ) + } + + verify(authenticationViewModel).loadAuthenticationUserData(any(), any()) + verify(chatViewModel, never()).connectUser(any(), any(), any(), any()) + } + + @Test + fun loadAndThenConnectClientFails() { + doAnswer { invocation -> + val onSuccess = invocation.arguments[0] as () -> Unit + onSuccess() + } + .`when`(authenticationViewModel) + .loadAuthenticationUserData(any(), any()) + doAnswer { invocation -> + val onFailure = invocation.arguments[3] as (Exception) -> Unit + onFailure(RuntimeException("Failed to connect user")) + } + .`when`(chatViewModel) + .connectUser(any(), any(), any(), any()) + + composeTestRule.setContent { + ProfileScreen( + userViewModel, + authenticationViewModel, + pushNotificationsService, + chatViewModel, + navigationActions, + ) + } + + verify(authenticationViewModel).loadAuthenticationUserData(any(), any()) + verify(chatViewModel).connectUser(any(), any(), any(), any()) } } 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 56163cf8e..6ea767fc6 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 @@ -8,6 +8,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick 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.UserViewModel @@ -17,6 +18,7 @@ import com.android.periodpals.resources.C.Tag.TopAppBar import com.android.periodpals.ui.navigation.NavigationActions import com.android.periodpals.ui.navigation.Screen import com.android.periodpals.ui.navigation.TopLevelDestination +import io.github.kakaocup.kakao.common.utilities.getResourceString import org.junit.Before import org.junit.Rule import org.junit.Test @@ -61,7 +63,7 @@ class SettingsScreenTest { composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals("My Settings") + .assertTextEquals(getResourceString(R.string.settings_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsNotDisplayed() diff --git a/app/src/test/java/com/android/periodpals/ui/timer/TimerScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/timer/TimerScreenTest.kt index 2c7f32f4b..d207766db 100644 --- a/app/src/test/java/com/android/periodpals/ui/timer/TimerScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/timer/TimerScreenTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.lifecycle.MutableLiveData +import com.android.periodpals.R import com.android.periodpals.model.authentication.AuthenticationViewModel import com.android.periodpals.model.timer.COUNTDOWN_DURATION import com.android.periodpals.model.timer.Timer @@ -19,6 +20,7 @@ import com.android.periodpals.resources.C.Tag.TimerScreen import com.android.periodpals.resources.C.Tag.TopAppBar import com.android.periodpals.ui.navigation.NavigationActions import com.android.periodpals.ui.navigation.Route +import io.github.kakaocup.kakao.common.utilities.getResourceString import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -77,7 +79,7 @@ class TimerScreenTest { composeTestRule .onNodeWithTag(TopAppBar.TITLE_TEXT) .assertIsDisplayed() - .assertTextEquals("Tampon Timer") + .assertTextEquals(getResourceString(R.string.timer_screen_title)) composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.SETTINGS_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TopAppBar.CHAT_BUTTON).assertIsNotDisplayed() @@ -93,10 +95,16 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.START_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_start)) .assertHasClickAction() composeTestRule.onNodeWithTag(TimerScreen.RESET_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TimerScreen.STOP_BUTTON).assertIsNotDisplayed() composeTestRule.onNodeWithTag(TimerScreen.USEFUL_TIP).performScrollTo().assertIsDisplayed() + composeTestRule + .onNodeWithTag(TimerScreen.USEFUL_TIP_TEXT) + .performScrollTo() + .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_useful_tip_text)) } @Test @@ -166,6 +174,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.START_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_start)) .performClick() verify(timerViewModel).startTimer(any(), any()) } @@ -183,6 +192,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.START_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_start)) .performClick() verify(timerViewModel).startTimer(any(), any()) assertEquals(false, timerViewModel.isRunning.value) @@ -200,6 +210,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.START_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_start)) .assertHasClickAction() verify(timerViewModel, never()).startTimer(any(), any()) assertEquals(false, isRunning.value) @@ -220,6 +231,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.RESET_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_reset)) .performClick() verify(timerViewModel).resetTimer(any(), any()) assertEquals(false, isRunning.value) @@ -239,6 +251,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.RESET_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_reset)) .performClick() verify(timerViewModel).resetTimer(any(), any()) assertEquals(true, isRunning.value) @@ -257,6 +270,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.RESET_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_reset)) .assertHasClickAction() verify(timerViewModel, never()).resetTimer(any(), any()) assertEquals(true, isRunning.value) @@ -277,6 +291,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.STOP_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_stop)) .performClick() verify(timerViewModel).stopTimer(eq(UID), any(), any()) @@ -297,6 +312,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.STOP_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_stop)) .performClick() verify(timerViewModel).stopTimer(eq(UID), any(), any()) assertEquals(true, isRunning.value) @@ -315,6 +331,7 @@ class TimerScreenTest { .onNodeWithTag(TimerScreen.STOP_BUTTON) .performScrollTo() .assertIsDisplayed() + .assertTextEquals(getResourceString(R.string.timer_stop)) .assertHasClickAction() verify(timerViewModel, never()).stopTimer(any(), any(), any()) assertEquals(true, isRunning.value) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a95cee8d9..17194092e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,10 @@ firebaseBom = "33.6.0" formBuilder = "1.0.7" fragmentKtxVersion = "1.8.4" dexmakerMockitoInline = "2.28.4" +getstreamStreamChatAndroidOffline = "6.6.0" +getstreamStreamChatAndroidUiComponents = "6.6.0" imagepicker = "2.1" +javaJwt = "3.18.2" json = "20240303" kotlin = "2.0.0" gms = "4.4.2" @@ -21,6 +24,7 @@ ktorClientAndroidVersion = "3.0.0-rc-1" ktorClientMock = "3.0.0" materialIconsExtended = "1.7.3" landscapistGlide = "1.5.2" +materialIconsExtendedVersion = "1.6.0-alpha08" mockitoAndroid = "5.13.0" mockkVersion = "1.12.0" mockitoMockitoCore = "5.4.0" @@ -29,6 +33,12 @@ playServicesBase = "18.5.0" runner = "1.6.2" runtimeLivedata = "1.7.5" secretsGradlePlugin = "2.0.1" +streamChat = "v6.6.0" +streamChatAndroidCompose = "5.2.0" +streamChatAndroidComposeVersion = "6.6.0" +streamChatAndroidOffline = "5.2.0" +streamChatAndroidUiComponents = "4.8.0" +streamChatAndroidUiComponentsVersion = "5.2.0" ui = "1.6.8" uiTestJunit4 = "1.6.8" uiTestJunit4Version = "1.0.5" @@ -119,6 +129,7 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir form-builder = { module = "com.github.jkuatdsc:form-builder", version.ref = "formBuilder" } github-postgrest-kt = { module = "io.github.jan-tennert.supabase:postgrest-kt" } imagepicker = { module = "com.github.dhaval2404:imagepicker", version.ref = "imagepicker" } +java-jwt = { module = "com.auth0:java-jwt", version.ref = "javaJwt" } json = { module = "org.json:json", version.ref = "json" } junit = { module = "junit:junit", version.ref = "junit" } kaspresso = { module = "com.kaspersky.android-components:kaspresso", version.ref = "kaspresso" } @@ -142,6 +153,9 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "playServicesBase" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secretsGradlePlugin" } +stream-chat-android-compose-v660 = { module = "io.getstream:stream-chat-android-compose", version.ref = "streamChatAndroidComposeVersion" } +stream-chat-android-offline-v660 = { module = "io.getstream:stream-chat-android-offline", version.ref = "getstreamStreamChatAndroidOffline" } +stream-chat-android-ui-components-v660 = { module = "io.getstream:stream-chat-android-ui-components", version.ref = "getstreamStreamChatAndroidUiComponents" } storage-kt = { module = "io.github.jan-tennert.supabase:storage-kt" } test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidxCoreKtx" } androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } diff --git a/secrets.defaults.properties b/secrets.defaults.properties index 3334313f0..d64287cb7 100644 --- a/secrets.defaults.properties +++ b/secrets.defaults.properties @@ -5,4 +5,6 @@ POWERSYNC_URL=SAFE_DEFAULT_VALUE_POWERSYNC_URL SUPABASE_KEY=SAFE_DEFAULT_VALUE_SUPABASE_KEY SERVICE_KEY=SAFE_DEFAULT_VALUE_SERVICE_KEY SUPABASE_URL=SAFE_DEFAULT_VALUE_SUPABASE_URL -STADIA_MAPS_KEY=SAFE_DEFAULT_STADIA_MAPS_KEY \ No newline at end of file +STADIA_MAPS_KEY=SAFE_DEFAULT_STADIA_MAPS_KEY +STREAM_SDK_KEY=SAFE_DEFAULT_VALUE_STREAM_SDK_KEY +STREAM_SDK_SECRET=SAFE_DEFAULT_VALUE_STREAM_SDK_SECRET