diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82ffdc095..bedc21499 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: push: branches: - main + - 'setup/**' + - 'feat/**' pull_request: types: @@ -40,6 +42,16 @@ jobs: distribution: "temurin" java-version: "17" + # Hello Hello, Alonso here ^_~ + # For whatever reason Android SDK is weird and I can't get the emulator to work + # vvv this new plugin should install v12.0 that was published Sep 2024 to be updated + - name: Setup Android SDK + uses: Swisyn/setup-android-sdk@v1 + with: + cmdline-tools-version: 11076708 + ##TODO: Check if I need to install adb and avdmanager manually with cli cmds + + # Caching is a very useful part of a CI, as a workflow is executed in a clean environment every time, # this means that one would need to re-download and re-process gradle files for every run. Which is very time consuming. # @@ -66,7 +78,7 @@ jobs: arch: x86_64 force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false + disable-animations: true script: echo "Generated AVD snapshot for caching." - name: Grant execute permission for gradlew @@ -97,8 +109,9 @@ jobs: api-level: 34 target: google_apis arch: x86_64 + avd-name: github force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -skin 1080x1920 disable-animations: true script: ./gradlew connectedCheck --parallel --build-cache diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f5044e516..056046c76 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsKotlinAndroid) alias(libs.plugins.ktfmt) - //alias(libs.plugins.sonar) + // alias(libs.plugins.sonar) alias(libs.plugins.compose.compiler) id("jacoco") @@ -94,26 +94,28 @@ android { } } - sonar { properties { - property("sonar.projectKey", "PeriodPals_periodpals") + property("sonar.projectKey", "periodpals_periodpals") property("sonar.organization", "periodpals") property("sonar.host.url", "https://sonarcloud.io") // Comma-separated paths to the various directories containing the *.xml JUnit report files. // Each path may be absolute or relative to the project base directory. property( - "sonar.junit.reportPaths", - "${project.layout.buildDirectory.get()}/test-results/testDebugunitTest/") + "sonar.junit.reportPaths", + "${project.layout.buildDirectory.get()}/test-results/testDebugunitTest/", + ) // Paths to xml files with Android Lint issues. If the main flavor is changed, this file will // have to be changed too. property( - "sonar.androidLint.reportPaths", - "${project.layout.buildDirectory.get()}/reports/lint-results-debug.xml") + "sonar.androidLint.reportPaths", + "${project.layout.buildDirectory.get()}/reports/lint-results-debug.xml", + ) // Paths to JaCoCo XML coverage report files. property( - "sonar.coverage.jacoco.xmlReportPaths", - "${project.layout.buildDirectory.get()}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml") + "sonar.coverage.jacoco.xmlReportPaths", + "${project.layout.buildDirectory.get()}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml", + ) } } @@ -134,6 +136,17 @@ dependencies { // implementation(libs.androidx.fragment.ktx) // implementation(libs.kotlinx.serialization.json) + implementation(libs.compose) + implementation(libs.mockk.v1120) + implementation(libs.androidx.ui.test.junit4.v105) + implementation(libs.androidx.ui.test.manifest.v105) + + configurations.all { + resolutionStrategy { + force("androidx.test.ext:junit:1.1.5") + force("androidx.test.espresso:espresso-core:3.5.0") + } + } // supabase setup implementation(platform("io.github.jan-tennert.supabase:bom:3.0.0")) implementation(libs.github.postgrest.kt) @@ -148,6 +161,7 @@ dependencies { implementation(libs.material) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(platform(libs.compose.bom)) + implementation(libs.androidx.navigation.compose.v282) testImplementation(libs.junit) @@ -180,6 +194,12 @@ dependencies { // ---------- Robolectric ------------ testImplementation(libs.robolectric) + + // Material Icons + implementation(libs.androidx.material.icons.extended) + // Mockito for unit testing + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.core.v540) } tasks.withType { @@ -199,26 +219,27 @@ tasks.register("jacocoTestReport", JacocoReport::class) { } val fileFilter = - listOf( - "**/R.class", - "**/R$*.class", - "**/BuildConfig.*", - "**/Manifest*.*", - "**/*Test*.*", - "android/**/*.*", - ) + listOf( + "**/R.class", + "**/R$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*Test*.*", + "android/**/*.*", + ) val debugTree = - fileTree("${project.layout.buildDirectory.get()}/tmp/kotlin-classes/debug") { - exclude(fileFilter) - } + fileTree("${project.layout.buildDirectory.get()}/tmp/kotlin-classes/debug") { + exclude(fileFilter) + } val mainSrc = "${project.layout.projectDirectory}/src/main/java" sourceDirectories.setFrom(files(mainSrc)) classDirectories.setFrom(files(debugTree)) executionData.setFrom( - fileTree(project.layout.buildDirectory.get()) { - include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") - include("outputs/code_coverage/debugAndroidTest/connected/*/coverage.ec") - }) + fileTree(project.layout.buildDirectory.get()) { + include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + include("outputs/code_coverage/debugAndroidTest/connected/*/coverage.ec") + } + ) } diff --git a/app/src/androidTest/java/com/android/periodpals/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/android/periodpals/ExampleInstrumentedTest.kt deleted file mode 100644 index 03e490f71..000000000 --- a/app/src/androidTest/java/com/android/periodpals/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.android.periodpals - -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class MainActivityTest : TestCase() { - - @get:Rule val composeTestRule = createAndroidComposeRule() - - @Test - fun test() = - run { - // step("Start Main Activity") { - // ComposeScreen.onComposeScreen(composeTestRule) { - // simpleText { - // assertIsDisplayed() - // assertTextEquals("Hello Android!") - // } - // } - // } - } -} diff --git a/app/src/androidTest/java/com/android/periodpals/ui/alert/AlertScreenTest.kt b/app/src/androidTest/java/com/android/periodpals/ui/alert/AlertScreenTest.kt new file mode 100644 index 000000000..6da447b41 --- /dev/null +++ b/app/src/androidTest/java/com/android/periodpals/ui/alert/AlertScreenTest.kt @@ -0,0 +1,48 @@ +package com.android.periodpals.ui.alert + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import org.junit.Rule +import org.junit.Test + +class AlertScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun displayAllComponents() { + composeTestRule.setContent { MaterialTheme { AlertScreen() } } + + composeTestRule.onNodeWithTag("alertInstruction").assertIsDisplayed() + composeTestRule.onNodeWithTag("alertProduct").assertIsDisplayed() + composeTestRule.onNodeWithTag("alertUrgency").assertIsDisplayed() + composeTestRule.onNodeWithTag("alertLocation").assertIsDisplayed() + composeTestRule.onNodeWithTag("alertMessage").assertIsDisplayed() + composeTestRule + .onNodeWithTag("alertSubmit") + .assertIsDisplayed() + .assertTextEquals("Ask for Help") + } + + @Test + fun interactWithComponents() { + composeTestRule.setContent { MaterialTheme { AlertScreen() } } + + composeTestRule.onNodeWithTag("alertProduct").performClick() + composeTestRule.onNodeWithTag("Pads").performClick() + // composeTestRule.onNodeWithTag("alertProduct").assertTextEquals("Pads") + composeTestRule.onNodeWithTag("alertUrgency").performClick() + composeTestRule.onNodeWithTag("!! Medium").performClick() + // composeTestRule.onNodeWithTag("alertUrgency").assertTextEquals("!! Medium") + + composeTestRule.onNodeWithTag("alertLocation").performTextInput("Rolex") + composeTestRule.onNodeWithTag("alertMessage").performTextInput("I need help finding a tampon") + + composeTestRule.onNodeWithTag("alertSubmit").performClick() + } +} diff --git a/app/src/androidTest/java/com/android/periodpals/ui/navigation/BottomNavigationMenuTest.kt b/app/src/androidTest/java/com/android/periodpals/ui/navigation/BottomNavigationMenuTest.kt new file mode 100644 index 000000000..4826404a2 --- /dev/null +++ b/app/src/androidTest/java/com/android/periodpals/ui/navigation/BottomNavigationMenuTest.kt @@ -0,0 +1,96 @@ +package com.android.periodpals.ui.navigation + +import android.annotation.SuppressLint +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.junit.Rule +import org.junit.Test + +class BottomNavigationMenuTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun bottomNavigationMenu_displaysAllTabs() { + composeTestRule.setContent { + BottomNavigationMenu( + onTabSelect = {}, tabList = LIST_TOP_LEVEL_DESTINATION, selectedItem = "Map") + } + + composeTestRule.onNodeWithTag("bottomNavigationMenu").assertIsDisplayed() + LIST_TOP_LEVEL_DESTINATION.forEach { tab -> + composeTestRule.onNodeWithTag(tab.textId).assertIsDisplayed() + } + } + + @SuppressLint("UnrememberedMutableState") + @Test + fun bottomNavigationMenu_clickOnTab_changesSelection() { + var selectedTab by mutableStateOf(Route.MAP) // Initially selected tab is "MAP" + + // Set the composable content with an initial selected tab + composeTestRule.setContent { + BottomNavigationMenu( + onTabSelect = { selectedTab = it.route }, + tabList = LIST_TOP_LEVEL_DESTINATION, + selectedItem = selectedTab) + } + + // Initially, verify that "MAP" is selected + composeTestRule.onNodeWithTag("Map").assertIsSelected() + + // Perform a click on the "Alert" tab + composeTestRule.onNodeWithTag("Alert").performClick() + + // Now check that the "Alert" tab is selected + composeTestRule.onNodeWithTag("Alert").assertIsSelected() + + // Optionally, check that the previously selected "Map" tab is no longer selected + composeTestRule.onNodeWithTag("Map").assertIsNotSelected() + } + + @Test + fun bottomNavigationMenu_iconAndLabelAreDisplayedCorrectly() { + composeTestRule.setContent { + BottomNavigationMenu( + onTabSelect = {}, tabList = LIST_TOP_LEVEL_DESTINATION, selectedItem = "Profile") + } + + LIST_TOP_LEVEL_DESTINATION.forEach { tab -> + composeTestRule.onNodeWithTag(tab.textId).assertIsDisplayed() + composeTestRule.onNodeWithTag(tab.textId).assertIsDisplayed() + } + } + + @Test + fun bottomNavigationMenu_initialSelectionIsCorrect() { + composeTestRule.setContent { + BottomNavigationMenu( + onTabSelect = {}, tabList = LIST_TOP_LEVEL_DESTINATION, selectedItem = "Timer") + } + + composeTestRule.onNodeWithTag("Timer").assertIsSelected() + } + + @Test + fun bottomNavigationMenu_selectingSameTabDoesNotCrash() { + var selectedTab = Route.ALERT_LIST + + composeTestRule.setContent { + BottomNavigationMenu( + onTabSelect = { selectedTab = it.route }, + tabList = LIST_TOP_LEVEL_DESTINATION, + selectedItem = selectedTab) + } + + composeTestRule.onNodeWithTag("Alert List").performClick() + composeTestRule.onNodeWithTag("Alert List").assertIsSelected() + } +} diff --git a/app/src/androidTest/java/com/android/periodpals/ui/navigation/TopAppBarTest.kt b/app/src/androidTest/java/com/android/periodpals/ui/navigation/TopAppBarTest.kt new file mode 100644 index 000000000..8e0ff3753 --- /dev/null +++ b/app/src/androidTest/java/com/android/periodpals/ui/navigation/TopAppBarTest.kt @@ -0,0 +1,92 @@ +package com.android.periodpals.ui.navigation + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test + +class TopAppBarTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun topAppBar_displaysTitle() { + composeTestRule.setContent { TopAppBar(title = "Tampon Timer") } + + composeTestRule.onNodeWithTag("topBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("screenTitle").assertIsDisplayed() + } + + @Test + fun topAppBar_displaysBackButton() { + composeTestRule.setContent { TopAppBar(title = "Tampon Timer", backButton = true) } + + composeTestRule.onNodeWithTag("topBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("goBackButton").assertIsDisplayed() + } + + @Test + fun topAppBar_backButtonClick() { + var backButtonClicked = false + + composeTestRule.setContent { + TopAppBar( + title = "Tampon Timer", + backButton = true, + onBackButtonClick = { backButtonClicked = true }) + } + + composeTestRule.onNodeWithTag("goBackButton").performClick() + assert(backButtonClicked) + } + + @Test + fun topAppBar_noBackButton() { + composeTestRule.setContent { TopAppBar(title = "Tampon Timer", backButton = false) } + + composeTestRule.onNodeWithTag("topBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("goBackButton").assertDoesNotExist() + } + + @Test + fun topAppBar_backButtonTrue_onBackButtonClickNull_throwsException() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + composeTestRule.setContent { + TopAppBar(title = "Test Title", backButton = true, onBackButtonClick = null) + } + } + assert(exception.message == "onBackButtonClick must be provided when backButton is true") + } + + @Test + fun topAppBar_backButtonTrue_onBackButtonClickNotNull_doesNotThrowException() { + composeTestRule.setContent { + TopAppBar(title = "Test Title", backButton = true, onBackButtonClick = { /* Do nothing */}) + } + composeTestRule.onNodeWithTag("topBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("goBackButton").assertIsDisplayed() + } + + @Test + fun topAppBar_backButtonFalse_onBackButtonClickNull_doesNotThrowException() { + composeTestRule.setContent { + TopAppBar(title = "Test Title", backButton = false, onBackButtonClick = null) + } + composeTestRule.onNodeWithTag("topBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("goBackButton").assertIsNotDisplayed() + } + + @Test + fun topAppBar_backButtonFalse_onBackButtonClickNotNull_doesNotThrowException() { + composeTestRule.setContent { + TopAppBar(title = "Test Title", backButton = false, onBackButtonClick = { /* Do nothing */}) + } + composeTestRule.onNodeWithTag("topBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("goBackButton").assertIsNotDisplayed() + } +} diff --git a/app/src/androidTest/java/com/android/periodpals/ui/profile/CreateProfileTest.kt b/app/src/androidTest/java/com/android/periodpals/ui/profile/CreateProfileTest.kt new file mode 100644 index 000000000..5e3a21816 --- /dev/null +++ b/app/src/androidTest/java/com/android/periodpals/ui/profile/CreateProfileTest.kt @@ -0,0 +1,71 @@ +package com.android.periodpals.ui.profile + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CreateProfileTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun testProfileImageDisplayed() { + composeTestRule.setContent { CreateProfile() } + + // Check if the profile image is displayed + composeTestRule.onNodeWithTag("profile_image").assertIsDisplayed() + } + + @Test + fun testFormFieldsDisplayed() { + composeTestRule.setContent { CreateProfile() } + + // Check if the form fields are displayed + composeTestRule.onNodeWithTag("email_field").assertIsDisplayed() + composeTestRule.onNodeWithTag("dob_field").assertIsDisplayed() + composeTestRule.onNodeWithTag("name_field").assertIsDisplayed() + composeTestRule.onNodeWithTag("description_field").assertIsDisplayed() + } + + @Test + fun testSaveButtonClickWithValidDate() { + composeTestRule.setContent { CreateProfile() } + + // Input valid date + composeTestRule.onNodeWithTag("dob_field").performTextInput("01/01/2000") + + // Perform click on the save button + composeTestRule.onNodeWithTag("save_button").performClick() + composeTestRule.waitForIdle() + + assertTrue(validateDate("01/01/2000")) + assertTrue(validateDate("31/12/1999")) + } + + @Test + fun testSaveButtonClickWithInvalidDate() { + composeTestRule.setContent { CreateProfile() } + + // Input invalid date + composeTestRule.onNodeWithTag("dob_field").performTextInput("invalid_date") + + // Perform click on the save button + composeTestRule.onNodeWithTag("save_button").performClick() + composeTestRule.waitForIdle() + + assertFalse(validateDate("32/01/2000")) // Invalid day + assertFalse(validateDate("01/13/2000")) // Invalid month + assertFalse(validateDate("01/01/abcd")) // Invalid year + assertFalse(validateDate("01-01-2000")) // Invalid format + assertFalse(validateDate("01/01")) // Incomplete date + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 28126fa58..467e86bb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ android:theme="@style/Theme.PeriodPals" tools:targetApi="31"> @@ -26,5 +26,4 @@ - \ No newline at end of file diff --git a/app/src/main/java/com/android/periodpals/MainActivity.kt b/app/src/main/java/com/android/periodpals/MainActivity.kt index 704462b0e..e3096b391 100644 --- a/app/src/main/java/com/android/periodpals/MainActivity.kt +++ b/app/src/main/java/com/android/periodpals/MainActivity.kt @@ -1,5 +1,10 @@ package com.android.periodpals +// import androidx.navigation.compose.NavHost +// import androidx.navigation.compose.composable +// import androidx.navigation.navigation +// import com.android.periodpals.ui.navigation.Route +// import com.android.periodpals.ui.navigation.Screen import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -16,11 +21,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon.Companion.Text import androidx.compose.ui.unit.dp +import androidx.navigation.compose.rememberNavController +import com.android.periodpals.ui.navigation.NavigationActions import com.android.periodpals.ui.theme.PeriodPalsAppTheme -import io.github.jan.supabase.createSupabaseClient -import io.github.jan.supabase.postgrest.Postgrest -import io.github.jan.supabase.postgrest.from import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -33,21 +38,80 @@ class MainActivity : ComponentActivity() { PeriodPalsAppTheme { // A surface container using the 'background' color from the theme Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - CountriesList() + PeriodPalsApp() } } } } } +@Composable +fun PeriodPalsApp() { + val navController = rememberNavController() + val navigationActions = NavigationActions(navController) + + CountriesList() + + // TODO: Uncomment what has been implemented + + // NavHost(navController = navController, startDestination = Route.AUTH) { + // // Authentication + // navigation( + // startDestination = Screen.AUTH, + // route = Route.AUTH, + // ) { + // composable(Screen.AUTH) { SignInScreen(navigationActions) } + // composable(Screen.REGISTER) { RegisterScreen(navigationActions) } + // composable(Screen.CREATE_PROFILE) { CreateProfileScreen(navigationActions) } + // } + // + // // Alert push notifications + // navigation( + // startDestination = Screen.ALERT, + // route = Route.ALERT, + // ) { + // composable(Screen.ALERT) { AlertScreen(navigationActions) } + // } + // + // // Notifications received or pushed + // navigation( + // startDestination = Screen.ALERT_LIST, + // route = Route.ALERT_LIST, + // ) { + // composable(Screen.ALERT_LIST) { AlertListScreen(navigationActions) } + // } + // + // // Map + // navigation( + // startDestination = Screen.MAP, + // route = Route.MAP, + // ) { + // composable(Screen.MAP) { MapScreen(navigationActions) } + // } + // + // // Timer + // navigation( + // startDestination = Screen.TIMER, + // route = Route.TIMER, + // ) { + // composable(Screen.TIMER) { TimerScreen(navigationActions) } + // } + // + // // Profile + // navigation( + // startDestination = Screen.PROFILE, + // route = Route.PROFILE, + // ) { + // composable(Screen.PROFILE) { ProfileScreen(navigationActions) } + // composable(Screen.EDIT_PROFILE) { EditProfileScreen(navigationActions) } + // } + // } +} + @Composable fun CountriesList(dispatcher: CoroutineDispatcher = Dispatchers.IO) { var countries by remember { mutableStateOf>(listOf()) } - LaunchedEffect(Unit) { - withContext(dispatcher) { - countries = supabase.from("countries").select().decodeList() - } - } + LaunchedEffect(Unit) { withContext(dispatcher) { countries = listOf(Country(1, "eyyo pogger")) } } LazyColumn { items( countries.size, @@ -60,14 +124,6 @@ fun CountriesList(dispatcher: CoroutineDispatcher = Dispatchers.IO) { } } -val supabase = - createSupabaseClient( - supabaseUrl = "https://bhhjdcvdcfrxczbudraf.supabase.co", - supabaseKey = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJoaGpkY3ZkY2ZyeGN6YnVkcmFmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mjc4ODA4MjMsImV4cCI6MjA0MzQ1NjgyM30.teiPmTsMGNbXBx808uX7enVVLdgxqn4ftvSKjIgfCyQ") { - install(Postgrest) - } - @Serializable data class Country( val id: Int, diff --git a/app/src/main/java/com/android/periodpals/ui/alert/AlertScreen.kt b/app/src/main/java/com/android/periodpals/ui/alert/AlertScreen.kt new file mode 100644 index 000000000..c0a93cd21 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/alert/AlertScreen.kt @@ -0,0 +1,134 @@ +package com.android.periodpals.ui.alert + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Preview +@Composable +fun AlertScreen() { + var location by remember { mutableStateOf("") } + var message by remember { mutableStateOf("") } + + // TODO("TOP APP BAR and BOTTOM NAVIGATION") + Scaffold( + modifier = Modifier.testTag("alertScreen"), + content = { paddingValues -> + Column( + modifier = Modifier.fillMaxSize().padding(30.dp).padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly) { + // Text 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!", + modifier = Modifier.testTag("alertInstruction"), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall) + + // Product selection + ExposedDropdownMenuSample( + listOf("Tampons", "Pads", "No Preference"), "Product Needed", "alertProduct") + + // Urgency indicator + ExposedDropdownMenuSample( + listOf("!!! High", "!! Medium", "! Low"), "Urgency level", "alertUrgency") + + // Location + OutlinedTextField( + value = location, + onValueChange = { location = it }, + label = { Text("Location") }, + placeholder = { Text("Enter your location") }, + modifier = Modifier.fillMaxWidth().testTag("alertLocation")) + + // Message Box + OutlinedTextField( + value = message, + onValueChange = { message = it }, + label = { Text("Message") }, + placeholder = { Text("Write a message for the other users") }, + modifier = Modifier.fillMaxWidth().height(150.dp).testTag("alertMessage")) + + // Submit Button + Button( + onClick = { + // TODO("Save alert on supabase + navigation to + // AlertListScreen") + }, + modifier = + Modifier.width(300.dp).height(100.dp).testTag("alertSubmit").padding(16.dp), + ) { + Text("Ask for Help", style = MaterialTheme.typography.headlineMedium) + } + } + }) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExposedDropdownMenuSample(list: List, label: String, testTag: String) { + var options = list + var expanded by remember { mutableStateOf(false) } + var text by remember { mutableStateOf("Please choose one option") } + + ExposedDropdownMenuBox( + modifier = Modifier.testTag(testTag), + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + // The `menuAnchor` modifier must be passed to the text field to handle + // expanding/collapsing the menu on click. A read-only text field has + // the anchor type `PrimaryNotEditable`. + modifier = Modifier.menuAnchor(), + value = text, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + modifier = Modifier.testTag(option), + text = { Text(option) }, + onClick = { + text = option + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} diff --git a/app/src/main/java/com/android/periodpals/ui/navigation/BottomNavigationMenu.kt b/app/src/main/java/com/android/periodpals/ui/navigation/BottomNavigationMenu.kt new file mode 100644 index 000000000..4e7c19dc2 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/navigation/BottomNavigationMenu.kt @@ -0,0 +1,67 @@ +package com.android.periodpals.ui.navigation + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Displays a bottom navigation menu with selectable tabs. + * + * @param onTabSelect Called when a tab is selected, receiving the selected [TopLevelDestination]. + * @param tabList List of [TopLevelDestination] representing the tabs. + * @param selectedItem The route of the currently selected tab. + * + * ### Usage: + * ``` + * BottomNavigationMenu( + * onTabSelect = { route -> navigationActions.navigateTo(route) }, + * tabList = LIST_TOP_LEVEL_DESTINATION, + * selectedItem = navigationActions.currentRoute() + * ) + * ``` + * + * ### Testing: + * Verify the bottom navigation menu is displayed with the testTag "bottomNavigationMenu". + */ +@Composable +fun BottomNavigationMenu( + onTabSelect: (TopLevelDestination) -> Unit, + tabList: List, + selectedItem: String +) { + NavigationBar( + modifier = Modifier.fillMaxWidth().height(60.dp).testTag("bottomNavigationMenu"), + containerColor = MaterialTheme.colorScheme.surface, + content = { + tabList.forEach { tab -> + NavigationBarItem( + modifier = + Modifier.clip(RoundedCornerShape(50.dp)) + .align(Alignment.CenterVertically) + .testTag(tab.textId), + icon = { Icon(tab.icon, contentDescription = null) }, + label = { + Text( + text = tab.textId, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, fontWeight = FontWeight.Normal)) + }, + selected = tab.route == selectedItem, + onClick = { onTabSelect(tab) }) + } + }) +} diff --git a/app/src/main/java/com/android/periodpals/ui/navigation/NavigationActions.kt b/app/src/main/java/com/android/periodpals/ui/navigation/NavigationActions.kt new file mode 100644 index 000000000..9fe48b35a --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/navigation/NavigationActions.kt @@ -0,0 +1,110 @@ +package com.android.periodpals.ui.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.List +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.HourglassEmpty +import androidx.compose.material.icons.outlined.Map +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController + +object Route { + const val AUTH = "Auth" + const val ALERT = "Alert" + const val ALERT_LIST = "Alert List" + const val MAP = "Map" + const val TIMER = "Timer" + const val PROFILE = "Profile" +} + +object Screen { + const val AUTH = "Auth Screen" + const val ALERT = "Alert Screen" + const val ALERT_LIST = "AlertList Screen" + const val MAP = "Map Screen" + const val TIMER = "Timer Screen" + const val PROFILE = "Profile Screen" + const val REGISTER = "Register Screen" + const val CREATE_PROFILE = "CreateProfile Screen" + const val EDIT_PROFILE = "EditProfile Screen" + // TODO: Add as app is being built +} + +data class TopLevelDestination(val route: String, val icon: ImageVector, val textId: String) + +object TopLevelDestinations { + val ALERT = + TopLevelDestination(route = Route.ALERT, icon = Icons.Outlined.WarningAmber, textId = "Alert") + val ALERT_LIST = + TopLevelDestination( + route = Route.ALERT_LIST, icon = Icons.AutoMirrored.Outlined.List, textId = "Alert List") + val MAP = TopLevelDestination(route = Route.MAP, icon = Icons.Outlined.Map, textId = "Map") + val TIMER = + TopLevelDestination( + route = Route.TIMER, icon = Icons.Outlined.HourglassEmpty, textId = "Timer") + val PROFILE = + TopLevelDestination( + route = Route.PROFILE, icon = Icons.Outlined.AccountCircle, textId = "Profile") +} + +val LIST_TOP_LEVEL_DESTINATION = + listOf( + TopLevelDestinations.ALERT, + TopLevelDestinations.ALERT_LIST, + TopLevelDestinations.MAP, + TopLevelDestinations.TIMER, + TopLevelDestinations.PROFILE) + +open class NavigationActions(private val navController: NavHostController) { + /** + * Navigate to the specified [TopLevelDestination] + * + * @param destination The top level destination to navigate to Clear the back stack when + * navigating to a new destination This is useful when navigating to a new screen from the + * bottom navigation bar as we don't want to keep the previous screen in the back stack + */ + open fun navigateTo(destination: TopLevelDestination) { + + navController.navigate(destination.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + inclusive = true + } + + // Avoid multiple copies of the same destination when reselecting same item + launchSingleTop = true + + // Restore state when reselecting a previously selected item + if (destination.route != Route.AUTH) { + restoreState = true + } + } + } + + /** + * Navigate to the specified screen. + * + * @param screen The screen to navigate to + */ + open fun navigateTo(screen: String) { + navController.navigate(screen) + } + + /** Navigate back to the previous screen */ + open fun goBack() { + navController.popBackStack() + } + + /** + * Get the current route of the navigation controller. + * + * @return The current route + */ + open fun currentRoute(): String { + return navController.currentDestination?.route ?: "" + } +} diff --git a/app/src/main/java/com/android/periodpals/ui/navigation/TopAppBar.kt b/app/src/main/java/com/android/periodpals/ui/navigation/TopAppBar.kt new file mode 100644 index 000000000..bf6b5fb29 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/navigation/TopAppBar.kt @@ -0,0 +1,75 @@ +package com.android.periodpals.ui.navigation + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.android.periodpals.ui.theme.PurpleGrey80 + +/** + * Displays a top app bar with an optional back button. + * + * @param title The title text to be displayed in the app bar. + * @param backButton Whether to show a back button. Default is false. + * @param onBackButtonClick Called when the back button is clicked. + * + * ### Usage: + * The top app bar can be displayed with a title: + * ``` + * TopAppBar(title = "Tampon Timer") + * ``` + * + * To include a back button, e.g., on the edit profile screen: + * ```kotlin + * TopAppBar( + * title = "Tampon Timer", + * backButton = true, + * onBackButtonClick = { navigationActions.goBack() } + * ) + * ``` + * + * ### Testing: + * - Use the testTag "topBar" to verify the app bar is displayed. + * - If the back button is shown, check for the "goBackButton" tag to confirm its presence and + * functionality. + * - The title can be checked using the "screenTitle" testTag. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopAppBar(title: String, backButton: Boolean = false, onBackButtonClick: (() -> Unit)? = {}) { + require(!backButton || onBackButtonClick != null) { + "onBackButtonClick must be provided when backButton is true" + } + CenterAlignedTopAppBar( + modifier = Modifier.fillMaxWidth().height(48.dp).testTag("topBar"), + title = { + Text( + text = title, + modifier = Modifier.padding(12.dp).testTag("screenTitle"), + style = MaterialTheme.typography.titleMedium) + }, + navigationIcon = { + if (backButton) { + IconButton(onClick = onBackButtonClick!!, modifier = Modifier.testTag("goBackButton")) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(20.dp)) + } + } else null + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = PurpleGrey80)) +} 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 new file mode 100644 index 000000000..173691415 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/profile/CreateProfile.kt @@ -0,0 +1,198 @@ +package com.android.periodpals.ui.profile + +import android.app.Activity +import android.content.Intent +import android.icu.util.GregorianCalendar +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.android.periodpals.R +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun CreateProfile() { + var name by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var age by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + + var profileImageUri by remember { + mutableStateOf( + Uri.parse("android.resource://com.android.periodpals/" + R.drawable.generic_avatar)) + } + var context = LocalContext.current + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + profileImageUri = result.data?.data + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + content = { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(16.dp).padding(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier.size(124.dp) + .clip(shape = RoundedCornerShape(100.dp)) + .background( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(100.dp)) + .testTag("profile_image") + .clickable { + val pickImageIntent = Intent(Intent.ACTION_PICK).apply { type = "image/*" } + launcher.launch(pickImageIntent) + }) { + GlideImage( + model = profileImageUri, + contentDescription = "profile picture", + contentScale = ContentScale.Crop, + modifier = + Modifier.size(124.dp) + .background( + color = MaterialTheme.colorScheme.background, shape = CircleShape)) + } + + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Mandatory", + style = + TextStyle( + fontSize = 20.sp, + lineHeight = 20.sp, + fontWeight = FontWeight(500), + letterSpacing = 0.2.sp, + ), + ) + } + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + placeholder = { Text("Enter your email") }, + modifier = Modifier.testTag("email_field")) + + OutlinedTextField( + value = age, + onValueChange = { age = it }, + label = { Text("Date of Birth") }, + placeholder = { Text("DD/MM/YYYY") }, + modifier = Modifier.testTag("dob_field")) + + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Your profile", + style = + TextStyle( + fontSize = 20.sp, + lineHeight = 20.sp, + fontWeight = FontWeight(500), + letterSpacing = 0.2.sp, + ), + ) + } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Displayed Name") }, + placeholder = { Text("Enter your name") }, + modifier = Modifier.testTag("name_field"), + ) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + placeholder = { Text("Enter a description") }, + modifier = Modifier.height(124.dp).testTag("description_field"), + ) + + Button( + onClick = { + if (validateDate(age)) { + // Save the profile (future implementation) + Toast.makeText(context, "Profile saved", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "Invalid date", Toast.LENGTH_SHORT).show() + } + }, + enabled = true, + modifier = + Modifier.padding(0.dp) + .width(84.dp) + .height(40.dp) + .testTag("save_button") + .background( + color = Color(0xFF65558F), shape = RoundedCornerShape(size = 100.dp)), + colors = ButtonDefaults.buttonColors(Color(0xFF65558F))) { + Text( + "Save", + color = Color.White, + ) + } + } + }) +} + +fun validateDate(date: String): Boolean { + val parts = date.split("/") + val calendar = GregorianCalendar.getInstance() + calendar.isLenient = false + if (parts.size == 3) { + return try { + calendar.set(parts[2].toInt(), parts[1].toInt() - 1, parts[0].toInt()) + calendar.time + true + } catch (e: Exception) { + false + } + } + return false +} 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 455c4aa9e..aa77250fc 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 @@ -10,6 +10,7 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -20,9 +21,11 @@ private val DarkColorScheme = private val LightColorScheme = lightColorScheme( - primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, - /* Other default colors to override + // Other default colors to override background = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE), onPrimary = Color.White, @@ -30,8 +33,7 @@ private val LightColorScheme = onTertiary = Color.White, onBackground = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F), - */ - ) + ) @Composable fun PeriodPalsAppTheme( diff --git a/app/src/main/res/drawable/generic_avatar.png b/app/src/main/res/drawable/generic_avatar.png new file mode 100644 index 000000000..cf7c6c27c Binary files /dev/null and b/app/src/main/res/drawable/generic_avatar.png differ diff --git a/app/src/test/java/com/android/periodpals/ExampleRobolectricTest.kt b/app/src/test/java/com/android/periodpals/ExampleRobolectricTest.kt deleted file mode 100644 index 9c1276312..000000000 --- a/app/src/test/java/com/android/periodpals/ExampleRobolectricTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.android.periodpals - -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class SecondActivityTest : TestCase() { - - @get:Rule val composeTestRule = createAndroidComposeRule() - - @Test - fun test() = - run { - // step("Start Second Activity") { - // ComposeScreen.onComposeScreen(composeTestRule) { - // simpleText { - // assertIsDisplayed() - // assertTextEquals("Hello Robolectric!") - // } - // } - // } - } -} diff --git a/app/src/test/java/com/android/periodpals/ui/navigation/NavigationActionsTest.kt b/app/src/test/java/com/android/periodpals/ui/navigation/NavigationActionsTest.kt new file mode 100644 index 000000000..63ef9c3bd --- /dev/null +++ b/app/src/test/java/com/android/periodpals/ui/navigation/NavigationActionsTest.kt @@ -0,0 +1,108 @@ +package com.android.periodpals.ui.navigation + +import androidx.navigation.NavDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.eq + +class NavigationActionsTest { + + private lateinit var navigationDestination: NavDestination + private lateinit var navHostController: NavHostController + private lateinit var navigationActions: NavigationActions + + @Before + fun setUp() { + navigationDestination = mock(NavDestination::class.java) + navHostController = mock(NavHostController::class.java) + navigationActions = NavigationActions(navHostController) + } + + // TODO: Uncomment the tests + /** + * Test that the navigateTo function calls the navHostController with the correct route and + * NavOptionsBuilder. + */ + @Test + fun navigateToTopLevelDestinations() { + navigationActions.navigateTo(TopLevelDestinations.ALERT) + verify(navHostController).navigate(eq(Route.ALERT), any Unit>()) + navigationActions.navigateTo(TopLevelDestinations.ALERT_LIST) + verify(navHostController).navigate(eq(Route.ALERT_LIST), any Unit>()) + navigationActions.navigateTo(TopLevelDestinations.MAP) + verify(navHostController).navigate(eq(Route.MAP), any Unit>()) + navigationActions.navigateTo(TopLevelDestinations.TIMER) + verify(navHostController).navigate(eq(Route.TIMER), any Unit>()) + navigationActions.navigateTo(TopLevelDestinations.PROFILE) + verify(navHostController).navigate(eq(Route.PROFILE), any Unit>()) + } + + /** + * Test that the navigateTo function calls the navHostController with the correct route for the + * auth screens. + */ + @Test + fun navigateToAuthScreens() { + navigationActions.navigateTo(Screen.AUTH) + verify(navHostController).navigate(Screen.AUTH) + navigationActions.navigateTo(Screen.REGISTER) + verify(navHostController).navigate(Screen.REGISTER) + navigationActions.navigateTo(Screen.CREATE_PROFILE) + verify(navHostController).navigate(Screen.CREATE_PROFILE) + } + + /** + * Test that the navigateTo function calls the navHostController with the correct route for the + * other screens. + */ + @Test + fun navigateToOtherScreens() { + navigationActions.navigateTo(Screen.ALERT) + verify(navHostController).navigate(Screen.ALERT) + navigationActions.navigateTo(Screen.ALERT_LIST) + verify(navHostController).navigate(Screen.ALERT_LIST) + navigationActions.navigateTo(Screen.MAP) + verify(navHostController).navigate(Screen.MAP) + navigationActions.navigateTo(Screen.TIMER) + verify(navHostController).navigate(Screen.TIMER) + navigationActions.navigateTo(Screen.PROFILE) + verify(navHostController).navigate(Screen.PROFILE) + navigationActions.navigateTo(Screen.EDIT_PROFILE) + verify(navHostController).navigate(Screen.EDIT_PROFILE) + } + + /** Test that the goBack function calls the navHostController to pop the back stack. */ + @Test + fun goBackCallsController() { + navigationActions.goBack() + verify(navHostController).popBackStack() + } + + /** + * Test that the currentRoute function returns the correct route when the current destination is + * set. + */ + @Test + fun currentRouteWorksWithDestination() { + // Mock the current destination to be ALERT + `when`(navHostController.currentDestination).thenReturn(navigationDestination) + `when`(navigationDestination.route).thenReturn(Route.ALERT) + assertThat(navigationActions.currentRoute(), `is`(Route.ALERT)) + + // Mock the current destination to be MAP + `when`(navigationDestination.route).thenReturn(Route.MAP) + assertThat(navigationActions.currentRoute(), `is`(Route.MAP)) + + // Mock the current destination to be null + `when`(navHostController.currentDestination).thenReturn(null) + assertThat(navigationActions.currentRoute(), `is`("")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ff9299f1..f947036dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,10 @@ [versions] # Plugins -agp = "8.6.0" +agp = "8.6.1" +androidxNavigationCompose = "2.5.3" bomVersion = "3.0.0" +compose = "1.0.0-beta01" +imagepicker = "2.1" json = "20240303" kotlin = "2.0.0" gms = "4.4.2" @@ -10,11 +13,19 @@ ktfmt = "0.17.0" # UI Compose ktorClientAndroidVersion = "3.0.0-rc-1" +materialIconsExtended = "1.7.3" +landscapistGlide = "1.5.2" mockitoAndroid = "5.13.0" +mockkVersion = "1.12.0" +mockitoCoreVersion = "5.13.0" +mockitoMockitoCore = "5.4.0" +navigationComposeVersion = "2.8.2" runner = "1.6.2" ui = "1.6.8" uiTestJunit4 = "1.6.8" +uiTestJunit4Version = "1.0.5" uiTestManifest = "1.6.8" +uiTestManifestVersion = "1.0.5" uiTooling = "1.6.8" composeBom = "2024.08.00" constraintlayout = "2.1.4" @@ -62,11 +73,11 @@ material = "1.11.0" composeActivity = "1.8.2" composeViewModel = "2.7.0" sonar = "4.4.1.3373" +navigationCommonKtx = "2.8.2" +navigationRuntimeKtx = "2.8.2" [libraries] -androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } -androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } @@ -75,37 +86,38 @@ androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } -androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-navigation-compose-v282 = { module = "androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" } androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigationCompose" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigationCompose" } androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } androidx-ui = { module = "androidx.compose.ui:ui", version.ref = "ui" } androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } -androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "uiTestJunit4" } -androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "uiTestManifest" } +androidx-ui-test-junit4-v105 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "uiTestJunit4Version" } +androidx-ui-test-manifest-v105 = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "uiTestManifestVersion" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "ui" } -bom-v300 = { module = "io.github.jan-tennert.supabase:bom", version.ref = "bomVersion" } +compose = { module = "com.github.bumptech.glide:compose", version.ref = "compose" } core-ktx = { module = "com.google.android.play:core-ktx", version.ref = "coreKtxVersion" } github-postgrest-kt = { module = "io.github.jan-tennert.supabase:postgrest-kt" } +imagepicker = { module = "com.github.dhaval2404:imagepicker", version.ref = "imagepicker" } 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" } kaspresso-allure-support = { module = "com.kaspersky.android-components:kaspresso-allure-support", version.ref = "kaspressoAllureSupport" } -kaspresso-compose-support = { module = "com.kaspersky.android-components:kaspresso-compose-support", version.ref = "kaspressoComposeSupport" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json-v162 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJsonVersion" } ktor-client-android-v300rc1 = { module = "io.ktor:ktor-client-android", version.ref = "ktorClientAndroidVersion" } +landscapist-glide = { module = "com.github.skydoves:landscapist-glide", version.ref = "landscapistGlide" } material = { module = "com.google.android.material:material", version.ref = "materialVersion" } mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoAndroid" } -mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } +mockito-core-v540 = { module = "org.mockito:mockito-core", version.ref = "mockitoMockitoCore" } mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } -mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockkAgent" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockkAndroid" } +mockk-v1120 = { module = "io.mockk:mockk", version.ref = "mockkVersion" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidxCoreKtx" } @@ -114,7 +126,6 @@ androidx-ui-test-junit4-android = { group = "androidx.compose.ui", name = "ui-te auth-kt = { module = "io.github.jan-tennert.supabase:auth-kt" } bom = { module = "io.github.jan-tennert.supabase:bom", version.ref = "bom" } -ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktorClientAndroid" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } @@ -132,6 +143,8 @@ kaspresso-compose = { group = "com.kaspersky.android-components", name = "kaspre postgrest-kt = { module = "io.github.jan-tennert.supabase:postgrest-kt" } realtime-kt = { module = "io.github.jan-tennert.supabase:realtime-kt" } supabase-postgrest-kt = { module = "io.github.jan-tennert.supabase:postgrest-kt" } +androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" } +androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b164ff65d..a36a73e90 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Mar 11 13:43:48 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists \ No newline at end of file