diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/TestLocationProvider.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/TestLocationProvider.kt new file mode 100644 index 000000000..108fe16e3 --- /dev/null +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/TestLocationProvider.kt @@ -0,0 +1,20 @@ +package com.github.lookupgroup27.lookup + +import android.location.Location +import androidx.test.core.app.ApplicationProvider +import com.github.lookupgroup27.lookup.model.location.LocationProvider + +/** TestLocationProvider allows for manual setting of location values. */ +class TestLocationProvider : LocationProvider(ApplicationProvider.getApplicationContext()) { + fun setLocation(latitude: Double?, longitude: Double?) { + if (latitude != null && longitude != null) { + currentLocation.value = + Location("test").apply { + this.latitude = latitude + this.longitude = longitude + } + } else { + currentLocation.value = null + } + } +} diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/feed/FeedScreenTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/feed/FeedScreenTest.kt index 0718f474a..9335f7029 100644 --- a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/feed/FeedScreenTest.kt +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/feed/FeedScreenTest.kt @@ -11,7 +11,9 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.test.core.app.ApplicationProvider import androidx.test.rule.GrantPermissionRule +import com.github.lookupgroup27.lookup.TestLocationProvider import com.github.lookupgroup27.lookup.model.location.LocationProvider +import com.github.lookupgroup27.lookup.model.location.LocationProviderSingleton import com.github.lookupgroup27.lookup.model.post.Post import com.github.lookupgroup27.lookup.model.post.PostsRepository import com.github.lookupgroup27.lookup.model.profile.ProfileRepository @@ -22,6 +24,8 @@ import com.github.lookupgroup27.lookup.ui.navigation.TopLevelDestinations import com.github.lookupgroup27.lookup.ui.post.PostsViewModel import com.github.lookupgroup27.lookup.ui.profile.ProfileViewModel import com.google.firebase.auth.FirebaseAuth +import io.mockk.every +import io.mockk.mockkObject import org.junit.Before import org.junit.Rule import org.junit.Test @@ -64,10 +68,11 @@ class FeedScreenTest { // Mock posts val testPosts = listOf( - Post( + Post( // Post created by the logged-in user uid = "1", uri = "http://example.com/1.jpg", - username = testUserProfile.email, // Post created by the logged-in user + username = testUserProfile.email, + userMail = testUserProfile.email, latitude = 37.7749, // San Francisco longitude = -122.4194, description = "This is a test description"), @@ -75,13 +80,15 @@ class FeedScreenTest { uid = "2", uri = "http://example.com/2.jpg", username = "User2", // Post created by another user + userMail = "User2", latitude = 34.0522, // Los Angeles longitude = -118.2437, description = "This is another test description"), Post( uid = "3", uri = "http://example.com/3.jpg", - username = "User3", // Another user's post + username = "User3", + userMail = "User3", // Another user's post latitude = 36.7783, // Fresno (closer to SF) longitude = -119.4179, description = "This is yet another test description"), @@ -89,6 +96,7 @@ class FeedScreenTest { uid = "4", uri = "User4", username = "user4@example.com", // Another user's post + userMail = "User4", latitude = 40.7128, // New York City (farther from SF than LA or Fresno) longitude = -74.0060, description = "This is a test description"), @@ -96,6 +104,7 @@ class FeedScreenTest { uid = "5", uri = "User5", username = "user5@example.com", // Another user's post + userMail = "User5", latitude = -33.8688, // Sydney, Australia (farthest from SF) longitude = 151.2093, description = "This is a test description")) @@ -138,18 +147,30 @@ class FeedScreenTest { `when`(navigationActions.currentRoute()).thenReturn(Screen.FEED) locationProvider = LocationProvider(context, mutableStateOf(null)) + } + /** + * Helper function to set up the FeedScreen with the given list of nearby posts. + * + * @param initialNearbyPosts The list of posts to display initially on the feed screen. + * + * This function simplifies test setup by allowing each test to specify the initial state of the + * feed. It handles the rendering of the FeedScreen with the specified posts and ensures a + * consistent setup across all tests. + */ + private fun setFeedScreenContent(initialNearbyPosts: List) { composeTestRule.setContent { FeedScreen( postsViewModel = postsViewModel, navigationActions = navigationActions, profileViewModel = profileViewModel, - initialNearbyPosts = testPosts) + initialNearbyPosts = initialNearbyPosts) } } @Test fun testFeedScreenDisplaysNearbyPosts() { + setFeedScreenContent(testPosts) // Assert each post item is displayed composeTestRule.onNodeWithTag("PostItem_2").assertExists() @@ -163,12 +184,14 @@ class FeedScreenTest { @Test fun testFeedExcludesLoggedInUserPosts() { + setFeedScreenContent(testPosts) // Assert that the post by the logged-in user is not displayed composeTestRule.onNodeWithTag("PostItem_1").assertDoesNotExist() } @Test fun testBottomNavigationMenuIsDisplayed() { + setFeedScreenContent(testPosts) // Verify the bottom navigation menu is displayed composeTestRule @@ -179,6 +202,7 @@ class FeedScreenTest { @Test fun testStarClickDisplaysAverageRating() { + setFeedScreenContent(testPosts) // Perform click on the first star icon of a post with uid "1" composeTestRule .onNodeWithTag("Star_2_2") @@ -191,6 +215,7 @@ class FeedScreenTest { @Test fun testStarClickCallsUpdatePost() { + setFeedScreenContent(testPosts) // Perform click on the first star of post with uid "1" composeTestRule.onNodeWithTag("Star_2_2").performClick() postsViewModel.updatePost(testPost) @@ -211,6 +236,7 @@ class FeedScreenTest { @Test fun testNavigationToFeedBlockedForLoggedOutUser() { + setFeedScreenContent(testPosts) // Mock the user as not logged in mockAuth = org.mockito.kotlin.mock() whenever(mockAuth.currentUser).thenReturn(null) @@ -224,6 +250,7 @@ class FeedScreenTest { @Test fun testAddressIsDisplayed() { + setFeedScreenContent(testPosts) // Verify that the address is displayed for each post composeTestRule.onNodeWithTag("AddressTag_2").performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag("AddressTag_5").assertDoesNotExist() @@ -231,8 +258,66 @@ class FeedScreenTest { @Test fun testDescriptionIsDisplayed() { + setFeedScreenContent(testPosts) // Verify that the description is displayed for each post composeTestRule.onNodeWithTag("DescriptionTag_2").performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag("DescriptionTag_5").assertDoesNotExist() } + + @Test + fun testFeedDisplaysNoImagesMessageWhenPostsAreEmpty() { + // Arrange: Mock location provider and permissions + val testLocationProvider = TestLocationProvider() + testLocationProvider.setLocation(37.7749, -122.4194) // Mocked location + + mockkObject(LocationProviderSingleton) + every { LocationProviderSingleton.getInstance(any()) } returns testLocationProvider + + // Act: Render the FeedScreen with no posts + setFeedScreenContent(emptyList()) + + // Wait for the location to emit + composeTestRule.waitForIdle() + + // Assert: "No images available" message is displayed + composeTestRule.onNodeWithTag("feed_no_images_available").assertExists().assertIsDisplayed() + } + + @Test + fun testFeedDisplaysLoadingIndicatorWhenLocationIsNull() { + // Arrange: Mock location provider and permissions + val testLocationProvider = TestLocationProvider() + testLocationProvider.setLocation(null, null) // No location emitted + + mockkObject(LocationProviderSingleton) + every { LocationProviderSingleton.getInstance(any()) } returns testLocationProvider + + // Act: Render the FeedScreen + setFeedScreenContent(emptyList()) + + // Wait for the composition to stabilize + composeTestRule.waitForIdle() + + // Assert: Loading indicator (CircularProgressIndicator) is displayed + composeTestRule.onNodeWithTag("loading_indicator_test_tag").assertExists().assertIsDisplayed() + } + + @Test + fun testFeedDisplaysNoImagesMessageWithPlaceholderImage() { + // Arrange: Mock location provider and permissions + val testLocationProvider = TestLocationProvider() + testLocationProvider.setLocation(37.7749, -122.4194) // Mocked location + + mockkObject(LocationProviderSingleton) + every { LocationProviderSingleton.getInstance(any()) } returns testLocationProvider + + // Act: Render the FeedScreen with no posts + setFeedScreenContent(emptyList()) + + // Wait for any updates to complete + composeTestRule.waitForIdle() + + // Assert: Placeholder image is displayed + composeTestRule.onNodeWithTag("no_images_placeholder").assertExists().assertIsDisplayed() + } } diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/image/ImageReviewKtTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/image/ImageReviewKtTest.kt index 4b6bbdb31..452f61dcc 100644 --- a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/image/ImageReviewKtTest.kt +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/image/ImageReviewKtTest.kt @@ -1,12 +1,7 @@ package com.github.lookupgroup27.lookup.ui.image -import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.lookupgroup27.lookup.model.collection.CollectionRepository import com.github.lookupgroup27.lookup.model.image.ImageRepository @@ -21,7 +16,6 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito -import org.mockito.Mockito.`when` import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -40,7 +34,6 @@ class ImageReviewTest { @get:Rule val composeTestRule = createComposeRule() private val fakeFile: File = File.createTempFile("temp", null) - private val mockNavigationActions: NavigationActions = mock() @Before @@ -53,13 +46,10 @@ class ImageReviewTest { collectionRepository = Mockito.mock(CollectionRepository::class.java) collectionViewModel = CollectionViewModel(collectionRepository) - - // Mock UID generator to return a fixed value - `when`(postsViewModel.generateNewUid()).thenReturn("mocked_uid") } @Test - fun testImageReviewIsDisplayed() { + fun testImageReviewScreenIsDisplayed() { composeTestRule.setContent { ImageReviewScreen( mockNavigationActions, @@ -69,7 +59,6 @@ class ImageReviewTest { collectionViewModel, timestamp = 123456789L) } - composeTestRule.onNodeWithTag("image_review").assertIsDisplayed() } @@ -84,7 +73,6 @@ class ImageReviewTest { collectionViewModel, timestamp = 123456789L) } - composeTestRule.onNodeWithTag("confirm_button").performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag("confirm_button").performClick() } @@ -100,13 +88,59 @@ class ImageReviewTest { collectionViewModel, timestamp = 123456789L) } - composeTestRule.onNodeWithTag("cancel_button").performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag("cancel_button").performClick() verify(mockNavigationActions).navigateTo(Screen.TAKE_IMAGE) } + @Test + fun testDescriptionFieldIsDisplayedAndEditable() { + composeTestRule.setContent { + ImageReviewScreen( + mockNavigationActions, + fakeFile, + imageViewModel, + postsViewModel, + collectionViewModel, + timestamp = 123456789L) + } + composeTestRule.onNodeWithTag("description_title").assertIsDisplayed() + // Initially, the description field is displayed in read-only mode + composeTestRule.onNodeWithTag("description_text").assertIsDisplayed().performClick() + + // Enter edit mode and input text + composeTestRule + .onNodeWithTag("edit_description_field") + .assertIsDisplayed() + .performTextInput("New Description") + + // Verify that the input text is displayed correctly + composeTestRule.onNodeWithTag("edit_description_field").assert(hasText("New Description")) + } + + @Test + fun testLoadingIndicatorReplacesPostButtonWhenUploading() { + // Simulate loading state + imageViewModel.setEditImageState(ImageViewModel.UploadStatus(isLoading = true)) + + composeTestRule.setContent { + ImageReviewScreen( + mockNavigationActions, + fakeFile, + imageViewModel, + postsViewModel, + collectionViewModel, + timestamp = 123456789L) + } + + // Verify that the loading indicator is displayed + composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed() + + // Verify that the Post button is not displayed during loading + composeTestRule.onNodeWithTag("confirm_button").assertDoesNotExist() + } + @Test fun testImageDisplayedWhenImageFileIsNotNull() { val imageFile = File("path/to/image") @@ -137,7 +171,7 @@ class ImageReviewTest { } @Test - fun testImageReviewScreenIsScrollable() { + fun testDiscardButtonNavigatesBack() { composeTestRule.setContent { ImageReviewScreen( mockNavigationActions, @@ -148,15 +182,10 @@ class ImageReviewTest { timestamp = 123456789L) } - // Check that the top element is displayed (e.g., image or text) - composeTestRule.onNodeWithTag("image_review").assertIsDisplayed() - - // Attempt to scroll to a specific button at the bottom - composeTestRule.onNodeWithTag("cancel_button").performScrollTo().assertIsDisplayed() - composeTestRule.onNodeWithTag("confirm_button").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("cancel_button").performClick() + verify(mockNavigationActions).navigateTo(Screen.TAKE_IMAGE) } - /** Verifies that the background image is displayed in the EditImageScreen. */ @Test fun testBackgroundImageIsDisplayed() { composeTestRule.setContent { @@ -171,19 +200,19 @@ class ImageReviewTest { composeTestRule.onNodeWithTag("background_image").assertIsDisplayed() } - /** Verifies that the loading indicator is displayed when the state is set to Loading. */ @Test - fun testLoadingIndicatorIsDisplayedWhenStateIsLoading() { - imageViewModel.setEditImageState(ImageViewModel.UploadStatus(isLoading = true)) + fun testTitleIsDisplayed() { composeTestRule.setContent { ImageReviewScreen( - mockNavigationActions, - fakeFile, + navigationActions = mockNavigationActions, + imageFile = fakeFile, imageViewModel, postsViewModel, collectionViewModel, timestamp = 123456789L) } - composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed() + + // Verify that the title "Post Your Picture" is displayed + composeTestRule.onNodeWithTag("post_picture_title").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/overview/MenuKtTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/overview/MenuKtTest.kt index e447b2937..43fc3d21f 100644 --- a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/overview/MenuKtTest.kt +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/overview/MenuKtTest.kt @@ -153,4 +153,17 @@ class MenuKtTest { // Verify navigation to Google Map screen is triggered verify(mockNavigationActions).navigateTo(Screen.GOOGLE_MAP) } + + @Test + fun menuScreen_clickPlanets_navigatesToPlanetSelectionScreen() { + composeTestRule.setContent { + MenuScreen(navigationActions = mockNavigationActions, mockAvatarViewModel) + } + + // Perform click on "Planets" button + composeTestRule.onNodeWithText("Planets").performClick() + + // Verify navigation to PlanetSelection screen is triggered + verify(mockNavigationActions).navigateTo(Screen.PLANET_SELECTION) + } } diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetButtonTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetButtonTest.kt new file mode 100644 index 000000000..42c685bb6 --- /dev/null +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetButtonTest.kt @@ -0,0 +1,61 @@ +package com.github.lookupgroup27.lookup.ui.planetselection.components + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import com.github.lookupgroup27.lookup.R +import com.github.lookupgroup27.lookup.model.map.planets.PlanetData +import org.junit.Rule +import org.junit.Test + +class PlanetButtonTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun planetButton_displaysCorrectIcon() { + val planet = PlanetData(name = "Mars", "301", iconRes = R.drawable.mars_icon, textureId = 0) + + composeTestRule.setContent { PlanetButton(planet = planet, onClick = {}) } + + // Assert that the button contains the correct icon + composeTestRule.onNodeWithContentDescription("Mars button").assertExists() + } + + @Test + fun planetButton_isClickable() { + val planet = PlanetData(name = "Mars", "301", iconRes = R.drawable.mars_icon, textureId = 0) + var clicked = false + + composeTestRule.setContent { PlanetButton(planet = planet, onClick = { clicked = true }) } + + // Perform click on the button + composeTestRule.onNodeWithContentDescription("Mars button").performClick() + + // Assert that the button click triggers the onClick action + assert(clicked) { "Planet button click did not trigger the onClick action" } + } + + @Test + fun planetButton_hasCorrectSizeAndPadding() { + val planet = PlanetData(name = "Mars", "301", iconRes = R.drawable.mars_icon, textureId = 0) + + composeTestRule.setContent { PlanetButton(planet = planet, onClick = {}) } + + // Assert that the button has the correct size + composeTestRule.onNodeWithContentDescription("Mars button").assertHeightIsEqualTo(48.dp) + composeTestRule.onNodeWithContentDescription("Mars button").assertWidthIsEqualTo(48.dp) + } + + @Test + fun planetButton_backgroundIsBlack() { + val planet = PlanetData(name = "Mars", "301", iconRes = R.drawable.mars_icon, textureId = 0) + + composeTestRule.setContent { PlanetButton(planet = planet, onClick = {}) } + + // Assert that the button has the correct background color + composeTestRule + .onNodeWithContentDescription("Mars button") + .assertExists() // Additional color checks need more advanced libraries + } +} diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetSelectionRowTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetSelectionRowTest.kt new file mode 100644 index 000000000..fb8f531f0 --- /dev/null +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetSelectionRowTest.kt @@ -0,0 +1,49 @@ +package com.github.lookupgroup27.lookup.ui.planetselection.components + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.github.lookupgroup27.lookup.R +import com.github.lookupgroup27.lookup.model.map.planets.PlanetData +import org.junit.Rule +import org.junit.Test + +class PlanetSelectionRowTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun planetSelectionRow_displaysAllPlanets() { + val planets = + listOf( + PlanetData(name = "Mercury", "499", iconRes = R.drawable.mercury_icon, textureId = 0), + PlanetData(name = "Venus", "499", iconRes = R.drawable.venus_icon, textureId = 1), + PlanetData(name = "Mars", "499", iconRes = R.drawable.mars_icon, textureId = 3)) + + composeTestRule.setContent { PlanetSelectionRow(planets = planets, onPlanetSelected = {}) } + + // Assert that all planets are displayed + planets.forEach { planet -> + composeTestRule.onNodeWithContentDescription("${planet.name} button").assertExists() + } + } + + @Test + fun planetSelectionRow_planetButtonIsClickable() { + val planets = + listOf(PlanetData(name = "Mars", "499", iconRes = R.drawable.mars_icon, textureId = 0)) + + var selectedPlanet: PlanetData? = null + + composeTestRule.setContent { + PlanetSelectionRow(planets = planets, onPlanetSelected = { selectedPlanet = it }) + } + + // Perform click on the Mars button + composeTestRule.onNodeWithContentDescription("Mars button").performClick() + + // Assert that the onPlanetSelected lambda is triggered with the correct planet + assert(selectedPlanet == planets.first()) { + "Mars button click did not select the correct planet." + } + } +} diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/register/RegisterKtTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/register/RegisterKtTest.kt index d1bebac6e..9e1b2a1ea 100644 --- a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/register/RegisterKtTest.kt +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/register/RegisterKtTest.kt @@ -3,6 +3,7 @@ package com.github.lookupgroup27.lookup.ui.register import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollTo import androidx.navigation.NavHostController import androidx.test.core.app.ApplicationProvider import com.github.lookupgroup27.lookup.model.register.RegisterRepository @@ -11,7 +12,9 @@ import org.junit.Rule import org.junit.Test class MockRegisterRepository : RegisterRepository { - override suspend fun registerUser(email: String, password: String) {} + override suspend fun registerUser(email: String, password: String, username: String) { + // Mock implementation, does nothing. + } } class RegisterKtTest { @@ -43,6 +46,14 @@ class RegisterKtTest { composeTestRule.onNodeWithTag("screen_title").assertIsDisplayed() } + @Test + fun usernameField_isDisplayed() { + composeTestRule.setContent { + RegisterScreen(viewModel = createMockViewModel(), navigationActions = mockNavigationActions()) + } + composeTestRule.onNodeWithTag("username_field").assertIsDisplayed() + } + @Test fun emailField_isDisplayed() { composeTestRule.setContent { @@ -72,7 +83,7 @@ class RegisterKtTest { composeTestRule.setContent { RegisterScreen(viewModel = createMockViewModel(), navigationActions = mockNavigationActions()) } - composeTestRule.onNodeWithTag("register_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("register_button").performScrollTo().assertIsDisplayed() } @Test diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/model/map/renderables/LabelTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/util/opengl/LabelUtilsTest.kt similarity index 60% rename from app/src/androidTest/java/com/github/lookupgroup27/lookup/model/map/renderables/LabelTest.kt rename to app/src/androidTest/java/com/github/lookupgroup27/lookup/util/opengl/LabelUtilsTest.kt index 830f81c7a..b3e72e998 100644 --- a/app/src/androidTest/java/com/github/lookupgroup27/lookup/model/map/renderables/LabelTest.kt +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/util/opengl/LabelUtilsTest.kt @@ -1,29 +1,14 @@ -package com.github.lookupgroup27.lookup.model.map.renderables +package com.github.lookupgroup27.lookup.util.opengl import android.graphics.Bitmap import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.github.lookupgroup27.lookup.model.map.renderables.label.Label -import com.github.lookupgroup27.lookup.model.map.renderables.label.LabelUtils import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith /** Instrumented tests for LabelUtils and the Label class. */ @RunWith(AndroidJUnit4::class) -class LabelTest { - - /** Tests that a Label object can be created successfully. */ - @Test - fun testLabelCreation() { - val text = "Star A" - val position = floatArrayOf(1.0f, 2.0f, 3.0f) - val label = Label(text, position) - - assertEquals("Label text should be 'Star A'", "Star A", label.text) - assertArrayEquals( - "Label position should match", floatArrayOf(1.0f, 2.0f, 3.0f), label.position, 0.0f) - assertNull("Label textureId should be null by default", label.textureId) - } +class LabelUtilsTest { /** Tests that the bitmap is created successfully. */ @Test @@ -33,7 +18,7 @@ class LabelTest { assertNotNull("Bitmap should not be null", bitmap) assertEquals("Bitmap width should be 256", 256, bitmap.width) - assertEquals("Bitmap height should be 128", 128, bitmap.height) + assertEquals("Bitmap height should be 256", 256, bitmap.height) } /** Tests that the bitmap contains non-transparent pixels. */ diff --git a/app/src/main/assets/shaders/label_fragment_shader.glsl b/app/src/main/assets/shaders/label_fragment_shader.glsl new file mode 100644 index 000000000..bb765f066 --- /dev/null +++ b/app/src/main/assets/shaders/label_fragment_shader.glsl @@ -0,0 +1,8 @@ +precision mediump float; + +uniform sampler2D uTexture; +varying vec2 vTexCoordinate; + +void main() { + gl_FragColor = texture2D(uTexture, vTexCoordinate); +} \ No newline at end of file diff --git a/app/src/main/assets/shaders/label_vertex_shader.glsl b/app/src/main/assets/shaders/label_vertex_shader.glsl new file mode 100644 index 000000000..8771db411 --- /dev/null +++ b/app/src/main/assets/shaders/label_vertex_shader.glsl @@ -0,0 +1,13 @@ +uniform mat4 uModelMatrix; +uniform mat4 uViewMatrix; +uniform mat4 uProjMatrix; + +attribute vec4 aPosition; +attribute vec2 aTexCoordinate; + +varying vec2 vTexCoordinate; + +void main() { + gl_Position = uProjMatrix * uViewMatrix * uModelMatrix * aPosition; + vTexCoordinate = aTexCoordinate; +} \ No newline at end of file diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/MainActivity.kt b/app/src/main/java/com/github/lookupgroup27/lookup/MainActivity.kt index 20bc5c632..d3aeaeb43 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/MainActivity.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/MainActivity.kt @@ -34,6 +34,8 @@ import com.github.lookupgroup27.lookup.ui.overview.LandingScreen import com.github.lookupgroup27.lookup.ui.overview.MenuScreen import com.github.lookupgroup27.lookup.ui.passwordreset.PasswordResetScreen import com.github.lookupgroup27.lookup.ui.passwordreset.PasswordResetViewModel +import com.github.lookupgroup27.lookup.ui.planetselection.PlanetSelectionScreen +import com.github.lookupgroup27.lookup.ui.planetselection.PlanetSelectionViewModel import com.github.lookupgroup27.lookup.ui.post.PostsViewModel import com.github.lookupgroup27.lookup.ui.profile.CollectionScreen import com.github.lookupgroup27.lookup.ui.profile.CollectionViewModel @@ -82,6 +84,8 @@ fun LookUpApp() { viewModel(factory = PasswordResetViewModel.Factory) val avatarViewModel: AvatarViewModel = viewModel(factory = AvatarViewModel.Factory) val loginViewModel: LoginViewModel = viewModel(factory = LoginViewModel.Factory) + val planetSelectionViewModel: PlanetSelectionViewModel = + viewModel(factory = PlanetSelectionViewModel.createFactory(context)) NavHost(navController = navController, startDestination = Route.LANDING) { navigation( @@ -119,6 +123,9 @@ fun LookUpApp() { GoogleMapScreen(navigationActions, postsViewModel, profileViewModel) } composable(Screen.QUIZ) { QuizScreen(quizViewModel, navigationActions) } + composable(Screen.PLANET_SELECTION) { + PlanetSelectionScreen(planetSelectionViewModel, navigationActions) + } } navigation(startDestination = Screen.QUIZ, route = Route.QUIZ) { diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/feed/ProximityFetcher.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/feed/ProximityFetcher.kt deleted file mode 100644 index 55c81d865..000000000 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/feed/ProximityFetcher.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.github.lookupgroup27.lookup.model.feed - -import android.content.Context -import android.util.Log -import com.github.lookupgroup27.lookup.model.location.LocationProviderSingleton -import com.github.lookupgroup27.lookup.model.post.Post -import com.github.lookupgroup27.lookup.ui.post.PostsViewModel -import com.github.lookupgroup27.lookup.util.LocationUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** - * ProximityPostFetcher is responsible for fetching posts and filtering them based on their - * proximity to the user's current location and also on the time the posts were taken. It updates a - * list of the most recent and nearby posts based on distance from the user. - * - * @param postsViewModel ViewModel to access the list of all posts - * @param context Application context to access the singleton LocationProvider - */ -class ProximityAndTimePostFetcher(private val postsViewModel: PostsViewModel, context: Context) { - // LocationProvider instance to get user's current location - private val locationProvider = LocationProviderSingleton.getInstance(context) - - // MutableStateFlow to hold the list of nearby posts - private val _nearbyPosts = MutableStateFlow>(emptyList()) - val nearbyPosts: StateFlow> = _nearbyPosts - - /** - * Fetches nearby posts with images based on the user's current location and the time they were - * posted. Filters and sorts posts by distance and time, limiting results to the 3 closest posts. - */ - fun fetchSortedPosts() { - val userLocation = locationProvider.currentLocation.value - if (userLocation == null) { - Log.e("ProximityPostFetcher", "User location is null; cannot fetch nearby posts.") - return - } - - // Launch a coroutine to collect and process posts - CoroutineScope(Dispatchers.IO).launch { - postsViewModel.allPosts.collect { posts -> - if (posts.isNotEmpty()) { - // Map each post to a pair of (post, distance) from the user - val sortedNearbyPosts = - posts - .mapNotNull { post -> - // Calculate the distance from the user's location to each post's location - val distance = - LocationUtils.calculateDistance( - userLocation.latitude, - userLocation.longitude, - post.latitude, - post.longitude) - - // Pair each post with its distance from the user - post to distance - } - // Sort first by distance (ascending), then by timestamp (descending) - .sortedWith( - compareBy> { it.second } // Sort by distance - .thenByDescending { it.first.timestamp } // Sort by timestamp - ) - // Take the 3 closest posts for now - .take(10) - // Extract only the post objects from the pairs - .map { it.first } - - // Update _nearbyPosts with the sorted list of nearby posts - _nearbyPosts.update { sortedNearbyPosts } - } else { - Log.e("fetchPosts", "Posts are empty.") - } - } - } - } -} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/MapRenderer.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/MapRenderer.kt index 05bd408c1..738c9bcc8 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/MapRenderer.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/MapRenderer.kt @@ -1,17 +1,17 @@ package com.github.lookupgroup27.lookup.model.map -import PlanetsRepository import android.content.Context import android.opengl.GLES20 import android.opengl.GLSurfaceView import android.util.Log import com.github.lookupgroup27.lookup.R -import com.github.lookupgroup27.lookup.model.loader.StarsLoader +import com.github.lookupgroup27.lookup.model.map.planets.PlanetsRepository import com.github.lookupgroup27.lookup.model.map.renderables.Planet import com.github.lookupgroup27.lookup.model.map.renderables.Star import com.github.lookupgroup27.lookup.model.map.renderables.utils.RayUtils.calculateRay import com.github.lookupgroup27.lookup.model.map.skybox.SkyBox import com.github.lookupgroup27.lookup.model.map.stars.StarDataRepository +import com.github.lookupgroup27.lookup.model.map.stars.StarsLoader import com.github.lookupgroup27.lookup.util.opengl.TextureManager import javax.microedition.khronos.egl.EGLConfig import javax.microedition.khronos.opengles.GL10 @@ -35,8 +35,6 @@ class MapRenderer( private var skyBoxTextureHandle: Int = -1 // Handle for the skybox texture - private val renderableObjects = mutableListOf() // List of objects to render - private val viewport = IntArray(4) /** The camera used to draw the shapes on the screen. */ @@ -68,6 +66,17 @@ class MapRenderer( initializeObjects() } + private var lastFrameTime: Long = System.currentTimeMillis() + + var timeProvider: () -> Long = { System.currentTimeMillis() } // Default time provider + + fun computeDeltaTime(): Float { + val currentTime = timeProvider() + val deltaTime = (currentTime - lastFrameTime) / 1000f + lastFrameTime = currentTime + return deltaTime + } + /** * Called to redraw the frame. Clears the screen, updates the camera view, and renders objects in * the scene. @@ -82,11 +91,15 @@ class MapRenderer( // Bind the texture and render the SkyBox GLES20.glDepthMask(false) textureManager.bindTexture(skyBoxTextureHandle) - // skyBox.draw(camera) commented out until the texture is changed to see stars + skyBox.draw(camera) GLES20.glDepthMask(true) - // Draw the objects in the scene + val deltaTime = computeDeltaTime() + // Update planet rotations + renderablePlanets.forEach { it.updateRotation(deltaTime) } + + // Draw the objects in the scene drawObjects() } @@ -119,7 +132,7 @@ class MapRenderer( private fun drawObjects() { // Renderable Objects renderableStars.forEach { o -> o.draw(camera) } - renderablePlanets.forEach { o -> o.draw(camera) } + renderablePlanets.forEach { o -> o.draw(camera, null) } } /** diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/planets/PlanetData.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/planets/PlanetData.kt index c86d3ef39..66e55b781 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/planets/PlanetData.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/planets/PlanetData.kt @@ -15,5 +15,6 @@ data class PlanetData( var ra: Double = 0.0, // Right Ascension in degrees var dec: Double = 0.0, // Declination in degrees var cartesian: Triple = Triple(0.0f, 0.0f, 0.0f), // Cartesian coordinates - val textureId: Int + val textureId: Int, + val iconRes: Int = 0 // New field for the button icon ) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/planets/PlanetsRepository.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/planets/PlanetsRepository.kt index c5d9ff531..fcfd114da 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/planets/PlanetsRepository.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/planets/PlanetsRepository.kt @@ -1,10 +1,11 @@ +package com.github.lookupgroup27.lookup.model.map.planets + import android.content.Context import com.github.lookupgroup27.lookup.R import com.github.lookupgroup27.lookup.model.location.LocationProvider -import com.github.lookupgroup27.lookup.model.map.planets.PlanetData import com.github.lookupgroup27.lookup.model.map.renderables.Moon import com.github.lookupgroup27.lookup.model.map.renderables.Planet -import com.github.lookupgroup27.lookup.utils.CelestialObjectsUtils +import com.github.lookupgroup27.lookup.util.CelestialObjectsUtils import java.io.IOException import java.text.SimpleDateFormat import java.util.Calendar @@ -41,14 +42,40 @@ class PlanetsRepository( // texture. planets.addAll( listOf( - PlanetData("Moon", "301", textureId = R.drawable.full_moon), - PlanetData("Mercury", "199", textureId = R.drawable.mercury_texture), - PlanetData("Venus", "299", textureId = R.drawable.venus_texture), - PlanetData("Mars", "499", textureId = R.drawable.mars_texture), - PlanetData("Jupiter", "599", textureId = R.drawable.jupiter_texture), - PlanetData("Saturn", "699", textureId = R.drawable.saturn_texture), - PlanetData("Uranus", "799", textureId = R.drawable.uranus_texture), - PlanetData("Neptune", "899", textureId = R.drawable.neptune_texture))) + PlanetData( + "Moon", "301", textureId = R.drawable.full_moon, iconRes = R.drawable.moon_icon), + PlanetData( + "Mercury", + "199", + textureId = R.drawable.mercury_texture, + iconRes = R.drawable.mercury_icon), + PlanetData( + "Venus", + "299", + textureId = R.drawable.venus_texture, + iconRes = R.drawable.venus_icon), + PlanetData( + "Mars", "499", textureId = R.drawable.mars_texture, iconRes = R.drawable.mars_icon), + PlanetData( + "Jupiter", + "599", + textureId = R.drawable.jupiter_texture, + iconRes = R.drawable.jupiter_icon), + PlanetData( + "Saturn", + "699", + textureId = R.drawable.saturn_texture, + iconRes = R.drawable.saturn_icon), + PlanetData( + "Uranus", + "799", + textureId = R.drawable.uranus_texture, + iconRes = R.drawable.uranus_icon), + PlanetData( + "Neptune", + "899", + textureId = R.drawable.neptune_texture, + iconRes = R.drawable.neptune_icon))) } /** diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/CircleRenderer.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/CircleRenderer.kt index 6a67eb69c..433b4536c 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/CircleRenderer.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/CircleRenderer.kt @@ -7,8 +7,8 @@ import com.github.lookupgroup27.lookup.model.map.renderables.utils.GeometryUtils import com.github.lookupgroup27.lookup.model.map.skybox.buffers.ColorBuffer import com.github.lookupgroup27.lookup.model.map.skybox.buffers.IndexBuffer import com.github.lookupgroup27.lookup.model.map.skybox.buffers.VertexBuffer -import com.github.lookupgroup27.lookup.util.ShaderUtils.readShader import com.github.lookupgroup27.lookup.util.opengl.ShaderProgram +import com.github.lookupgroup27.lookup.util.opengl.ShaderUtils.readShader /** * Renderer for creating circular 2D objects in an OpenGL environment. diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Moon.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Moon.kt index ab8ee96a4..ea4c92bd1 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Moon.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Moon.kt @@ -1,8 +1,9 @@ package com.github.lookupgroup27.lookup.model.map.renderables import android.content.Context +import androidx.annotation.VisibleForTesting import com.github.lookupgroup27.lookup.R -import com.github.lookupgroup27.lookup.utils.CelestialObjectsUtils +import com.github.lookupgroup27.lookup.util.CelestialObjectsUtils import java.util.Calendar import java.util.TimeZone @@ -30,7 +31,8 @@ class Moon( position = position, textureId = getCurrentMoonPhaseTextureId(), numBands = numBands, - stepsPerBand = stepsPerBand) { + stepsPerBand = stepsPerBand, + scale = 0.05f) { /** Companion object containing moon phase calculation and texture mapping logic. */ companion object { /** @@ -49,7 +51,8 @@ class Moon( * @param calendar The calendar instance to calculate the moon phase from. * @return Resource ID for the moon phase texture. */ - private fun getMoonPhaseTextureId(calendar: Calendar): Int { + @VisibleForTesting + fun getMoonPhaseTextureId(calendar: Calendar): Int { // Approximate lunar cycle calculation val year = calendar.get(Calendar.YEAR) val month = calendar.get(Calendar.MONTH) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Object.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Object.kt index b5f5ff959..71c0d0760 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Object.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Object.kt @@ -1,14 +1,4 @@ package com.github.lookupgroup27.lookup.model.map.renderables -import com.github.lookupgroup27.lookup.model.map.Camera - /** Represents an object in the OpenGL world. */ -abstract class Object { - - /** - * Draw the object on the screen. - * - * @param camera the camera to use to draw the object - */ - abstract fun draw(camera: Camera) -} +abstract class Object {} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Planet.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Planet.kt index 0d159d23c..6d3d92bf3 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Planet.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Planet.kt @@ -4,6 +4,8 @@ import android.content.Context import android.opengl.GLES20 import android.opengl.Matrix import com.github.lookupgroup27.lookup.model.map.Camera +import com.github.lookupgroup27.lookup.ui.map.renderables.Label +import com.github.lookupgroup27.lookup.util.opengl.Position import com.github.lookupgroup27.lookup.util.opengl.TextureManager /** @@ -30,12 +32,12 @@ import com.github.lookupgroup27.lookup.util.opengl.TextureManager */ open class Planet( private val context: Context, - val name: String? = "Planet", + val name: String = "Planet", val position: FloatArray = floatArrayOf(0.0f, 0.0f, -2.0f), - protected var textureId: Int, + var textureId: Int, numBands: Int = SphereRenderer.DEFAULT_NUM_BANDS, stepsPerBand: Int = SphereRenderer.DEFAULT_STEPS_PER_BAND, - private val scale: Float = 0.3f + private val scale: Float = 0.02f ) : Object() { private val sphereRenderer = SphereRenderer(context, numBands, stepsPerBand) @@ -44,6 +46,12 @@ open class Planet( protected var textureHandle: Int = 0 private var textureManager: TextureManager + private val label = + Label(context, name, Position(position[0], position[1], position[2]), 0.1f, scale) + + // New properties for rotation + private var rotationAngle: Float = 0f // Current rotation angle in degrees + private val rotationSpeed: Float = 50f // Rotation speed in degrees per second (constant for all) /** Initializes the planet's geometry, shaders, and texture. */ init { @@ -67,30 +75,46 @@ open class Planet( textureHandle = textureManager.loadTexture(textureId) } + /** + * Updates the rotation of the planet. This method should be called once per frame, and the + * deltaTime parameter should be passed to calculate the incremental rotation based on the speed. + * + * @param deltaTime Time elapsed since the last frame, in seconds. + */ + fun updateRotation(deltaTime: Float) { + rotationAngle = (rotationAngle + rotationSpeed * deltaTime) % 360f + } + /** * Renders the planet using the provided camera. * * @param camera The camera used for rendering the scene. */ - override fun draw(camera: Camera) { + fun draw(camera: Camera, transformMatrix: FloatArray? = null) { + label.draw(camera) val modelMatrix = FloatArray(16) Matrix.setIdentityM(modelMatrix, 0) - val viewMatrix = FloatArray(16) - val projMatrix = FloatArray(16) - // Copy camera matrices to avoid modification - System.arraycopy(camera.viewMatrix, 0, viewMatrix, 0, 16) - System.arraycopy(camera.projMatrix, 0, projMatrix, 0, 16) + // Apply the provided transform matrix or use the default planet position + if (transformMatrix != null) { + System.arraycopy(transformMatrix, 0, modelMatrix, 0, 16) + } else { + Matrix.translateM(modelMatrix, 0, position[0], position[1], position[2]) + Matrix.scaleM(modelMatrix, 0, scale, scale, scale) + } + + val viewMatrix = camera.viewMatrix + val projMatrix = camera.projMatrix + val mvpMatrix = FloatArray(16) + + // Combine matrices: Projection * View * Model - // Apply object transformations - Matrix.translateM(modelMatrix, 0, position[0], position[1], position[2]) - Matrix.scaleM(modelMatrix, 0, scale, scale, scale) + // Apply rotation transformation + Matrix.rotateM(modelMatrix, 0, rotationAngle, 0f, 1f, 0f) // Rotate around the Y-axis // Combine model, view, and projection matrices in correct order val viewModelMatrix = FloatArray(16) Matrix.multiplyMM(viewModelMatrix, 0, viewMatrix, 0, modelMatrix, 0) - - val mvpMatrix = FloatArray(16) Matrix.multiplyMM(mvpMatrix, 0, projMatrix, 0, viewModelMatrix, 0) // Pass final MVP matrix to the renderer @@ -108,6 +132,11 @@ open class Planet( sphereRenderer.unbindShaderAttributes() } + fun updateTexture(newTextureId: Int) { + textureId = newTextureId + loadTexture() // Reload the texture using the new ID + } + /** * Checks if a ray intersects the planet's surface. * diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/SphericalRenderable.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/SphericalRenderable.kt index 03501d7f8..6011fd5a7 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/SphericalRenderable.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/SphericalRenderable.kt @@ -7,8 +7,8 @@ import com.github.lookupgroup27.lookup.model.map.skybox.buffers.ColorBuffer import com.github.lookupgroup27.lookup.model.map.skybox.buffers.IndexBuffer import com.github.lookupgroup27.lookup.model.map.skybox.buffers.TextureBuffer import com.github.lookupgroup27.lookup.model.map.skybox.buffers.VertexBuffer -import com.github.lookupgroup27.lookup.util.ShaderUtils.readShader import com.github.lookupgroup27.lookup.util.opengl.ShaderProgram +import com.github.lookupgroup27.lookup.util.opengl.ShaderUtils.readShader /** * Base class for rendering spherical 3D objects in an OpenGL environment. diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Star.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Star.kt index 9ce843da8..48d95b446 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Star.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/Star.kt @@ -29,7 +29,7 @@ class Star( circleRenderer.initializeBuffers() } - override fun draw(camera: Camera) { + fun draw(camera: Camera) { // Model-View-Projection (MVP) Matrix val mvpMatrix = FloatArray(16) val modelMatrix = FloatArray(16) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/label/Label.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/label/Label.kt deleted file mode 100644 index 17222e843..000000000 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/label/Label.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.lookupgroup27.lookup.model.map.renderables.label - -/** - * Data class representing a label in the skymap. - * - * @property text The text of the label. - * @property position The position of the label in 3D space (x, y, z). - * @property textureId The OpenGL texture ID for the label's bitmap (optional). - */ -data class Label( - val text: String, - val position: FloatArray, - var textureId: Int? = null // Optional texture ID for OpenGL -) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/stars/StarDataRepository.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/stars/StarDataRepository.kt index 414edc944..b3ba76e86 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/stars/StarDataRepository.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/stars/StarDataRepository.kt @@ -2,7 +2,7 @@ package com.github.lookupgroup27.lookup.model.map.stars import android.content.Context import com.github.lookupgroup27.lookup.model.location.LocationProvider -import com.github.lookupgroup27.lookup.utils.CelestialObjectsUtils +import com.github.lookupgroup27.lookup.util.CelestialObjectsUtils /** * Repository class to manage star data, including: diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/stars/StarsLoader.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/stars/StarsLoader.kt index ffae05871..6a7b677da 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/stars/StarsLoader.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/map/stars/StarsLoader.kt @@ -1,8 +1,7 @@ -package com.github.lookupgroup27.lookup.model.loader +package com.github.lookupgroup27.lookup.model.map.stars import android.content.Context import com.github.lookupgroup27.lookup.model.map.renderables.Star -import com.github.lookupgroup27.lookup.model.map.stars.StarDataRepository /** * Converts star data into renderable objects for OpenGL rendering. @@ -26,7 +25,7 @@ class StarsLoader(private val context: Context, private val repository: StarData Star( context = context, position = floatArrayOf(starData.x.toFloat(), starData.y.toFloat(), starData.z.toFloat()), - size = 0.2f) + size = 0.002f) } } } diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetRenderer.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetRenderer.kt new file mode 100644 index 000000000..6a3b5d42b --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetRenderer.kt @@ -0,0 +1,102 @@ +package com.github.lookupgroup27.lookup.model.planetselection + +import android.content.Context +import android.opengl.GLES20 +import android.opengl.GLSurfaceView +import android.opengl.Matrix +import android.util.Log +import com.github.lookupgroup27.lookup.model.map.Camera +import com.github.lookupgroup27.lookup.model.map.planets.PlanetData +import com.github.lookupgroup27.lookup.model.map.renderables.Planet +import javax.microedition.khronos.egl.EGLConfig +import javax.microedition.khronos.opengles.GL10 + +/** + * Handles the rendering of a rotating planet using OpenGL. + * + * @param context The application context. + * @param planetData The planet data to render. + */ +class PlanetRenderer(private val context: Context, private var planetData: PlanetData) : + GLSurfaceView.Renderer { + + private lateinit var planet: Planet + + val camera = Camera(fov = 100f) // Field of View can be adjusted + + private var pendingTextureId: Int? = null + + private var lastFrameTime: Long = System.currentTimeMillis() + + override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { + try { + GLES20.glClearColor(0f, 0f, 0f, 1f) + GLES20.glEnable(GLES20.GL_DEPTH_TEST) + initializePlanet() + } catch (e: Exception) { + Log.e("PlanetRenderer", "Error in onSurfaceCreated", e) + // Optionally, you could reset to a default planet or show an error state + } + } + + override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) { + if (width == 0 || height == 0) { + Log.e("PlanetRenderer", "Invalid viewport dimensions: $width x $height") + } + GLES20.glViewport(0, 0, width, height) + camera.updateScreenRatio(width.toFloat() / height.toFloat()) // Update aspect ratio + } + + override fun onDrawFrame(gl: GL10?) { + // Clear the screen + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT) + + // Create a transformation matrix for the selection screen + val selectionMatrix = FloatArray(16) + Matrix.setIdentityM(selectionMatrix, 0) // Start with identity matrix + Matrix.translateM(selectionMatrix, 0, 0f, -1.0f, -3.5f) // Center at bottom, move back on Z + Matrix.scaleM(selectionMatrix, 0, 1.5f, 1.5f, 1.5f) // Uniform scale for visibility + + // Handle texture update on the GL thread + pendingTextureId?.let { + planet.updateTexture(it) // Update texture safely on the GL thread + pendingTextureId = null // Reset the flag + } + + // Calculate delta time + val currentTime = System.currentTimeMillis() + val deltaTime = (currentTime - lastFrameTime) / 1000f + lastFrameTime = currentTime + + // Update rotation and draw the planet + planet.updateRotation(deltaTime) + + // Apply the custom matrix to override planet position + planet.draw(camera, selectionMatrix) + } + + /** + * Updates the planet data and reinitializes the planet. + * + * @param newPlanetData The new planet data to render. + */ + fun updatePlanet(newPlanetData: PlanetData) { + try { + this.planetData = newPlanetData + initializePlanet() + } catch (e: Exception) { + Log.e("PlanetRenderer", "Failed to update planet", e) + } + } + + private fun initializePlanet() { + Log.d("PlanetRenderer", "Initializing Planet: ${planetData.name}") + planet = + Planet( + context = context, + name = planetData.name, + position = floatArrayOf(0f, 0f, -2.5f), + textureId = planetData.textureId) + Log.d("PlanetRenderer", "Planet Initialized with Texture ID: ${planetData.textureId}") + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetSurfaceView.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetSurfaceView.kt new file mode 100644 index 000000000..3f256bba5 --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetSurfaceView.kt @@ -0,0 +1,40 @@ +package com.github.lookupgroup27.lookup.model.planetselection + +import android.annotation.SuppressLint +import android.content.Context +import android.opengl.GLSurfaceView +import com.github.lookupgroup27.lookup.model.map.planets.PlanetData + +/** + * A custom GLSurfaceView that renders the currently selected planet using OpenGL. + * + * @param context The application context. + * @param planet The currently selected planet to render. + */ +@SuppressLint("ViewConstructor") +class PlanetSurfaceView(context: Context, private var planet: PlanetData) : GLSurfaceView(context) { + + private val planetRenderer: PlanetRenderer + + init { + // Set OpenGL ES version + setEGLContextClientVersion(2) + + // Initialize the PlanetRenderer + planetRenderer = PlanetRenderer(context, planet) + setRenderer(planetRenderer) + + // Render only when the content changes + renderMode = RENDERMODE_CONTINUOUSLY + } + + /** + * Update the currently displayed planet and notify the renderer. + * + * @param newPlanet The new planet data to render. + */ + fun updatePlanet(newPlanet: PlanetData) { + planet = newPlanet + queueEvent { planetRenderer.updatePlanet(newPlanet) } + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterRepository.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterRepository.kt index 6d9dc2c0a..10f7a589f 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterRepository.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterRepository.kt @@ -4,16 +4,24 @@ package com.github.lookupgroup27.lookup.model.register * Interface defining the contract for user registration repositories. * * Implementations of this interface are responsible for handling the registration logic, such as - * interacting with authentication services like FirebaseAuth. + * interacting with authentication services like FirebaseAuth, as well as Firestore for username + * uniqueness checks. */ interface RegisterRepository { /** - * Registers a new user with the provided email and password. + * Registers a new user with the provided email, password, and username. + * + * This method also ensures that the chosen username is unique among existing users. * * @param email The email address of the new user. * @param password The password for the new user. - * @throws Exception If an error occurs during the registration process. + * @param username The desired unique username for the new user. + * @throws UsernameAlreadyExistsException If the username is already associated with an existing + * user. + * @throws UserAlreadyExistsException If the email is already associated with an existing account. + * @throws WeakPasswordException If the password does not meet Firebase's security requirements. + * @throws Exception For any other unexpected errors during the registration process. */ - suspend fun registerUser(email: String, password: String) + suspend fun registerUser(email: String, password: String, username: String) } diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterRepositoryFirestore.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterRepositoryFirestore.kt index b740f74d6..0a85169e3 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterRepositoryFirestore.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterRepositoryFirestore.kt @@ -4,44 +4,66 @@ import android.util.Log import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthUserCollisionException import com.google.firebase.auth.FirebaseAuthWeakPasswordException +import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await /** - * Implementation of the [RegisterRepository] interface using Firebase Authentication. + * Implementation of the [RegisterRepository] interface using Firebase Authentication and Firestore. * - * This class handles the user registration process by interacting with FirebaseAuth to create new - * users. It captures specific exceptions thrown by FirebaseAuth and translates them into custom - * exceptions for better error handling in the ViewModel. + * This class handles the user registration process by: + * 1. Checking username uniqueness against the Firestore 'users' collection. + * 2. Creating the user with Firebase Authentication. + * 3. Storing the user's email and username in the Firestore 'users' collection. * - * @property auth The FirebaseAuth instance used to perform authentication operations. + * By separating these responsibilities into a repository, we maintain a clean MVVM architecture. + * + * @property auth The [FirebaseAuth] instance used to perform authentication operations. + * @property firestore The [FirebaseFirestore] instance used for username checks and data storage. */ -class RegisterRepositoryFirestore(private val auth: FirebaseAuth = FirebaseAuth.getInstance()) : - RegisterRepository { +class RegisterRepositoryFirestore( + private val auth: FirebaseAuth = FirebaseAuth.getInstance(), + private val firestore: FirebaseFirestore = FirebaseFirestore.getInstance() +) : RegisterRepository { /** - * Registers a new user with the provided email and password. + * Registers a new user with the provided email, password, and username. * - * This function uses FirebaseAuth to create a new user account. It handles specific exceptions - * thrown by FirebaseAuth and rethrows them as custom exceptions to be handled by the ViewModel. + * This function first checks whether the desired username is already taken. If not, it proceeds + * to create the user with [FirebaseAuth]. Once the user is successfully created, it stores their + * user data (email and username) in Firestore for future reference. * * @param email The email address of the new user. * @param password The password for the new user. + * @param username The desired unique username for the new user. + * @throws UsernameAlreadyExistsException If the chosen username is already taken. * @throws UserAlreadyExistsException If the email is already associated with an existing account. - * @throws WeakPasswordException If the password does not meet Firebase's security requirements. - * @throws Exception For any other errors during the registration process. + * @throws WeakPasswordException If the password does not meet security requirements. + * @throws Exception If any other unexpected errors occur during registration. */ - override suspend fun registerUser(email: String, password: String) { + override suspend fun registerUser(email: String, password: String, username: String) { try { - // Attempt to create a new user with the provided email and password. - auth.createUserWithEmailAndPassword(email, password).await() + // Check if username already exists in Firestore. + val querySnapshot = + firestore.collection("users").whereEqualTo("username", username).limit(1).get().await() + + if (!querySnapshot.isEmpty) { + throw UsernameAlreadyExistsException("Username '$username' is already in use.") + } + + // Attempt to create a new user in FirebaseAuth. + val authResult = auth.createUserWithEmailAndPassword(email, password).await() + val uid = authResult.user?.uid ?: throw Exception("Failed to retrieve user UID.") + + // Store user details in Firestore. + val userData = mapOf("email" to email, "username" to username) + firestore.collection("users").document(uid).set(userData).await() } catch (e: FirebaseAuthUserCollisionException) { - // Thrown if the email is already in use. throw UserAlreadyExistsException("An account with this email already exists.") } catch (e: FirebaseAuthWeakPasswordException) { - // Thrown if the password is not strong enough. throw WeakPasswordException("Your password is too weak.") + } catch (e: UsernameAlreadyExistsException) { + throw e } catch (e: Exception) { - // Log the error and rethrow a generic exception for any other errors. Log.e("RegisterRepository", "Error creating user", e) throw Exception("Registration failed due to an unexpected error.") } diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterState.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterState.kt index d6a4b559c..b6050a1f6 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterState.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/register/RegisterState.kt @@ -8,10 +8,12 @@ package com.github.lookupgroup27.lookup.model.register * * @property email The email input by the user. * @property password The password input by the user. - * @property confirmPassword The confirmation password input by the user. + * @property confirmPassword The confirm password input by the user. + * @property username The username input by the user. * @property emailError Error message related to the email input, if any. * @property passwordError Error message related to the password input, if any. * @property confirmPasswordError Error message related to the confirm password input, if any. + * @property usernameError Error message related to the username input, if any. * @property generalError General error message not specific to any field. * @property isLoading Indicates whether a registration operation is in progress. */ @@ -19,9 +21,11 @@ data class RegisterState( val email: String = "", val password: String = "", val confirmPassword: String = "", + val username: String = "", val emailError: String? = null, val passwordError: String? = null, val confirmPasswordError: String? = null, + val usernameError: String? = null, val generalError: String? = null, val isLoading: Boolean = false ) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/register/UsernameAlreadyExistsException.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/register/UsernameAlreadyExistsException.kt new file mode 100644 index 000000000..d26bf571b --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/register/UsernameAlreadyExistsException.kt @@ -0,0 +1,11 @@ +package com.github.lookupgroup27.lookup.model.register + +/** + * Exception thrown when attempting to register with a username that already exists in Firestore. + * + * This custom exception allows the application to inform the user that their chosen username is + * unavailable, prompting them to choose a different username. + * + * @param message The detail message for this exception. + */ +class UsernameAlreadyExistsException(message: String) : Exception(message) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/feed/Feed.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/feed/Feed.kt index bd6f8de56..01956ded5 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/feed/Feed.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/feed/Feed.kt @@ -3,6 +3,7 @@ package com.github.lookupgroup27.lookup.ui.feed import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager +import android.util.Log import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* @@ -23,7 +24,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.github.lookupgroup27.lookup.R -import com.github.lookupgroup27.lookup.model.feed.ProximityAndTimePostFetcher import com.github.lookupgroup27.lookup.model.location.LocationProviderSingleton import com.github.lookupgroup27.lookup.model.post.Post import com.github.lookupgroup27.lookup.model.profile.UserProfile @@ -62,8 +62,12 @@ fun FeedScreen( initialNearbyPosts: List? = null ) { // Fetch user profile - LaunchedEffect(Unit) { profileViewModel.fetchUserProfile() } + LaunchedEffect(Unit) { + Log.d("FeedScreen", "Fetching user profile") + profileViewModel.fetchUserProfile() + } + // User-related state val profile by profileViewModel.userProfile.collectAsState() val user = FirebaseAuth.getInstance().currentUser val isUserLoggedIn = user != null @@ -72,18 +76,22 @@ fun FeedScreen( val bio by remember { mutableStateOf(profile?.bio ?: "") } val email by remember { mutableStateOf(userEmail) } + // Location setup val context = LocalContext.current val locationProvider = LocationProviderSingleton.getInstance(context) - val proximityAndTimePostFetcher = remember { - ProximityAndTimePostFetcher(postsViewModel, context) + var locationPermissionGranted by remember { mutableStateOf(false) } + + // Initialize PostsViewModel with context + LaunchedEffect(Unit) { + Log.d("FeedScreen", "Setting context in PostsViewModel") + postsViewModel.setContext(context) } - var locationPermissionGranted by remember { mutableStateOf(false) } + // Posts-related state val unfilteredPosts by (initialNearbyPosts?.let { mutableStateOf(it) } - ?: proximityAndTimePostFetcher.nearbyPosts.collectAsState()) - val nearbyPosts = unfilteredPosts.filter { it.username != userEmail } - + ?: postsViewModel.nearbyPosts.collectAsState()) + val nearbyPosts = unfilteredPosts.filter { it.userMail != userEmail } val postRatings = remember { mutableStateMapOf>() } // Check for location permissions and fetch posts when granted. @@ -91,6 +99,7 @@ fun FeedScreen( locationPermissionGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + Log.d("FeedScreen", "Location permission granted: $locationPermissionGranted") if (!locationPermissionGranted) { Toast.makeText( @@ -100,7 +109,7 @@ fun FeedScreen( while (locationProvider.currentLocation.value == null) { delay(500) } - proximityAndTimePostFetcher.fetchSortedPosts() + postsViewModel.fetchSortedPosts() } } @@ -115,7 +124,7 @@ fun FeedScreen( } } - // Background Box with gradient overlay using drawBehind for efficiency. + // UI Structure Box( modifier = Modifier.fillMaxSize().drawBehind { @@ -162,19 +171,41 @@ fun FeedScreen( modifier = Modifier.fillMaxSize().padding(innerPadding).padding(horizontal = 8.dp)) { if (nearbyPosts.isEmpty()) { - // Loading or empty state Box( - modifier = - Modifier.fillMaxSize() - .testTag(stringResource(R.string.loading_indicator_test_tag)), + modifier = Modifier.fillMaxSize().testTag("loading_indicator_test_tag"), contentAlignment = Alignment.Center) { - if (!locationPermissionGranted) { - Text( - text = stringResource(R.string.location_permission_required), - style = - MaterialTheme.typography.bodyLarge.copy(color = Color.White)) - } else { - CircularProgressIndicator(color = Color.White) + when { + !locationPermissionGranted -> { + Text( + text = stringResource(R.string.location_permission_required), + style = + MaterialTheme.typography.bodyLarge.copy( + color = Color.White)) + } + locationProvider.currentLocation.value == null -> { + CircularProgressIndicator(color = Color.White) + } + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Image( + painter = + painterResource(R.drawable.no_images_placeholder), + contentDescription = + stringResource(R.string.feed_no_images_available), + modifier = + Modifier.size(180.dp) + .testTag("no_images_placeholder")) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.feed_no_images_available), + modifier = Modifier.testTag("feed_no_images_available"), + style = + MaterialTheme.typography.bodyLarge.copy( + color = Color.White)) + } + } } } } else { @@ -225,7 +256,6 @@ fun FeedScreen( } } } - /** * Updates the user's profile ratings. * diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/image/ImageReview.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/image/ImageReview.kt index ece950926..8e83dc899 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/image/ImageReview.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/image/ImageReview.kt @@ -3,23 +3,38 @@ package com.github.lookupgroup27.lookup.ui.image import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.rememberAsyncImagePainter @@ -31,9 +46,11 @@ import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions import com.github.lookupgroup27.lookup.ui.navigation.Screen import com.github.lookupgroup27.lookup.ui.post.PostsViewModel import com.github.lookupgroup27.lookup.ui.profile.CollectionViewModel +import com.github.lookupgroup27.lookup.ui.theme.DarkPurple import com.google.firebase.auth.FirebaseAuth import java.io.File +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ImageReviewScreen( navigationActions: NavigationActions, @@ -49,81 +66,166 @@ fun ImageReviewScreen( val uploadStatus by imageViewModel.uploadStatus.collectAsState() - // Scroll state val scrollState = rememberScrollState() + var description by remember { mutableStateOf("") } + var isEditing by remember { mutableStateOf(false) } + + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + Box( modifier = Modifier.fillMaxSize() .background(MaterialTheme.colorScheme.background) .testTag("image_review"), contentAlignment = Alignment.TopStart) { - // Background image Image( painter = painterResource(id = R.drawable.landing_screen_bckgrnd), contentDescription = "Background", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().blur(10.dp).testTag("background_image")) - // Save and Discard buttons aligned to the bottom center Column( - modifier = - Modifier.fillMaxWidth() - .align(Alignment.Center) - .padding(16.dp) - .verticalScroll(scrollState) // Make the column scrollable - .testTag("edit_buttons_column"), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Post Your Picture", + style = + MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, color = Color.White), + modifier = + Modifier.fillMaxWidth() + .padding(16.dp) + .align(Alignment.CenterHorizontally) + .testTag("post_picture_title"), // Center the title + textAlign = TextAlign.Center) + + Spacer(modifier = Modifier.height(10.dp)) + + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally) { + if (imageFile != null) { + Image( + painter = rememberAsyncImagePainter(imageFile), + contentDescription = "Captured Image", + modifier = + Modifier.fillMaxWidth() + .padding(16.dp) + .aspectRatio(1f) + .testTag("display_image"), + contentScale = ContentScale.Crop) + } else { + Text(text = "No image available", color = Color.White) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "Description", + modifier = + Modifier.padding(horizontal = 8.dp) + .align(Alignment.Start) + .testTag("description_title"), + style = + TextStyle( + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = MaterialTheme.typography.bodySmall.fontSize)) - // Display the image if available - if (imageFile != null) { - Image( - painter = rememberAsyncImagePainter(imageFile), - contentDescription = "Captured Image", - modifier = - Modifier.fillMaxWidth() - .padding(16.dp) - .aspectRatio(1f) - .testTag("display_image"), - contentScale = ContentScale.Crop) - } else { - Text(text = "No image available") - } - - Spacer(modifier = Modifier.height(65.dp)) - - // Save Image Button - - ActionButton( - text = "Save Image", - onClick = { imageFile?.let { imageViewModel.uploadImage(it) } }, - modifier = Modifier.testTag("confirm_button")) - - ActionButton( - text = "Discard Image", - onClick = { - Toast.makeText(context, "Image discarded", Toast.LENGTH_SHORT).show() - navigationActions.navigateTo(Screen.TAKE_IMAGE) - }, - modifier = Modifier.testTag("cancel_button"), - color = Color.Red) + if (isEditing) { + OutlinedTextField( + value = description, + onValueChange = { description = it }, + textStyle = TextStyle(color = Color.White), + placeholder = { + Text( + "Enter description", + style = + TextStyle( + color = Color.White.copy(alpha = 0.7f), + fontStyle = FontStyle.Italic)) + }, + colors = + TextFieldDefaults.outlinedTextFieldColors( + cursorColor = Color.White, + focusedBorderColor = Color.White, + unfocusedBorderColor = Color.White.copy(alpha = 0.5f)), + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(DarkPurple.copy(alpha = 0.5f)) + .padding(8.dp) + .focusRequester(focusRequester) + .testTag("edit_description_field"), + keyboardOptions = + KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + isEditing = false + keyboardController?.hide() + })) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + } else { + Text( + text = if (description.isEmpty()) "Add a description" else description, + color = Color.White, + style = + if (description.isEmpty()) + TextStyle(color = Color.White, fontStyle = FontStyle.Italic) + else TextStyle(color = Color.White), + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .height(75.dp) + .background(DarkPurple.copy(alpha = 0.5f)) + .padding(8.dp) + .clickable { isEditing = true } + .testTag("description_text")) + } + + Spacer(modifier = Modifier.height(35.dp)) + + if (uploadStatus.isLoading) { + CircularProgressIndicator( + modifier = + Modifier.size(35.dp) + .align(Alignment.CenterHorizontally) + .testTag("loading_indicator"), + color = DarkPurple) + } else { + + ActionButton( + text = "Post", + onClick = { imageFile?.let { imageViewModel.uploadImage(it) } }, + modifier = Modifier.testTag("confirm_button"), + color = DarkPurple) + } + + ActionButton( + text = "Discard Image", + onClick = { + Toast.makeText(context, "Image discarded", Toast.LENGTH_SHORT).show() + navigationActions.navigateTo(Screen.TAKE_IMAGE) + }, + modifier = Modifier.testTag("cancel_button").padding(vertical = 10.dp), + color = Color.Red) + } } // Display upload status messages and create a post upon successful upload when { - uploadStatus.isLoading -> { - CircularProgressIndicator( - modifier = - Modifier.align(BiasAlignment(0f, 0.30f)) - .padding(top = 16.dp) - .testTag("loading_indicator")) - } uploadStatus.downloadUrl != null -> { - val downloadUrl = uploadStatus.downloadUrl // Get the download URL from upload status + val downloadUrl = uploadStatus.downloadUrl val currentLocation = locationProvider.currentLocation.value if (downloadUrl != null && currentLocation != null) { - // Create a new post with the image download URL and current location val newPost = Post( uid = postsViewModel.generateNewUid(), @@ -132,8 +234,8 @@ fun ImageReviewScreen( username = FirebaseAuth.getInstance().currentUser?.displayName ?: "Anonymous", latitude = currentLocation.latitude, longitude = currentLocation.longitude, + description = description, timestamp = currentTimestamp) - // Add the post to PostsViewModel postsViewModel.addPost(newPost) collectionViewModel.updateImages() Toast.makeText(context, "Image saved", Toast.LENGTH_SHORT).show() @@ -143,7 +245,7 @@ fun ImageReviewScreen( context, "Failed to get current location or download URL", Toast.LENGTH_SHORT) .show() } - imageViewModel.resetUploadStatus() // Reset status after handling + imageViewModel.resetUploadStatus() } uploadStatus.errorMessage != null -> { val errorMessage = uploadStatus.errorMessage diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/map/MapViewModel.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/map/MapViewModel.kt index f814aa4b3..80f0a29c4 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/map/MapViewModel.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/map/MapViewModel.kt @@ -1,6 +1,5 @@ package com.github.lookupgroup27.lookup.ui.map -import PlanetsRepository import android.annotation.SuppressLint import android.content.Context import android.content.pm.ActivityInfo @@ -15,13 +14,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.github.lookupgroup27.lookup.model.location.LocationProviderSingleton import com.github.lookupgroup27.lookup.model.map.MapRenderer +import com.github.lookupgroup27.lookup.model.map.planets.PlanetsRepository import com.github.lookupgroup27.lookup.model.map.stars.StarDataRepository -// FOV constants -const val DEFAULT_FOV = 45f -const val MAX_FOV = DEFAULT_FOV + 40f -const val MIN_FOV = DEFAULT_FOV - 40f - /** The ViewModel for the map screen. */ class MapViewModel( context: Context, diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/map/renderables/Label.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/map/renderables/Label.kt new file mode 100644 index 000000000..3a8854672 --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/map/renderables/Label.kt @@ -0,0 +1,182 @@ +package com.github.lookupgroup27.lookup.ui.map.renderables + +import android.content.Context +import android.opengl.GLES20 +import android.opengl.GLUtils +import android.opengl.Matrix +import com.github.lookupgroup27.lookup.model.map.Camera +import com.github.lookupgroup27.lookup.util.opengl.BufferUtils.toBuffer +import com.github.lookupgroup27.lookup.util.opengl.LabelUtils +import com.github.lookupgroup27.lookup.util.opengl.Position +import com.github.lookupgroup27.lookup.util.opengl.ShaderProgram +import com.github.lookupgroup27.lookup.util.opengl.ShaderUtils.readShader +import java.nio.FloatBuffer + +/** + * A label that displays text in the 3D world. + * + * @param context The application context + * @param text The text to display on the label + * @param pos The position of the label TODO Implement this in others OpenGL classes + * @param size The size of the label + * @param objectSize The size of the object to label + */ +class Label(context: Context, text: String, var pos: Position, size: Float, objectSize: Float) { + private val shaderProgram: ShaderProgram + private val textureId: Int + private val vertexBuffer: FloatBuffer + private val texCoordBuffer: FloatBuffer + + companion object { + private const val PADDING = 0.025f + } + + init { + // Initialize shader program + val vertexShaderCode = readShader(context, "label_vertex_shader.glsl") + val fragmentShaderCode = readShader(context, "label_fragment_shader.glsl") + + shaderProgram = ShaderProgram(vertexShaderCode, fragmentShaderCode) + + // Define vertices for a quad that will display the label + // These coordinates represent a quad that fills the screen + val vertices = + floatArrayOf( + -size, + -size - objectSize - PADDING, + 0f, // Bottom left + size, + -size - objectSize - PADDING, + 0f, // Bottom right + -size, + size - objectSize - PADDING, + 0f, // Top left + size, + size - objectSize - PADDING, + 0f // Top right + ) + vertexBuffer = vertices.toBuffer() + + // Define texture coordinates + val texCoords = + floatArrayOf( + 0f, + 1f, // Bottom left + 1f, + 1f, // Bottom right + 0f, + 0f, // Top left + 1f, + 0f // Top right + ) + texCoordBuffer = texCoords.toBuffer() + + // Initialize Label texture + val textureHandles = IntArray(1) + GLES20.glGenTextures(1, textureHandles, 0) + textureId = textureHandles[0] + + // Bind texture + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId) + + // Set texture parameters + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR) + + // Load bitmap to OpenGL + val bitmap = LabelUtils.createLabelBitmap(text) + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0) + } + + /** + * Draws the label in the 3D world. + * + * @param camera The camera used to render the label + */ + fun draw(camera: Camera) { + GLES20.glEnable(GLES20.GL_BLEND) + GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA) + shaderProgram.use() + + // Get attribute and uniform locations + val modelMatrixHandle = GLES20.glGetUniformLocation(shaderProgram.programId, "uModelMatrix") + val viewMatrixHandle = GLES20.glGetUniformLocation(shaderProgram.programId, "uViewMatrix") + val projMatrixHandle = GLES20.glGetUniformLocation(shaderProgram.programId, "uProjMatrix") + val positionHandle = GLES20.glGetAttribLocation(shaderProgram.programId, "aPosition") + val texCoordHandle = GLES20.glGetAttribLocation(shaderProgram.programId, "aTexCoordinate") + val textureHandle = GLES20.glGetUniformLocation(shaderProgram.programId, "uTexture") + + // Bind texture + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId) + GLES20.glUniform1i(textureHandle, 0) + + // Set up vertex and texture coordinate buffers + vertexBuffer.position(0) + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer) + GLES20.glEnableVertexAttribArray(positionHandle) + + texCoordBuffer.position(0) + GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer) + GLES20.glEnableVertexAttribArray(texCoordHandle) + + val billboardMatrix = FloatArray(16) + val modelMatrix = camera.modelMatrix.clone() + + // Extract camera look direction from view matrix + val lookX = -camera.viewMatrix[2] + val lookY = -camera.viewMatrix[6] + val lookZ = -camera.viewMatrix[10] + + // Create billboard rotation (this is the vector compared to the object that points up for the + // text + // Example we take a text Hello, we have: + // ↑ Hello + val upX = camera.viewMatrix[1] + val upY = camera.viewMatrix[5] + val upZ = camera.viewMatrix[9] + + // Calculate right vector (cross product) + val rightX = upY * lookZ - upZ * lookY + val rightY = upZ * lookX - upX * lookZ + val rightZ = upX * lookY - upY * lookX + + // Set billboard matrix + billboardMatrix[0] = -rightX + billboardMatrix[1] = -rightY + billboardMatrix[2] = -rightZ + billboardMatrix[3] = 0f + + billboardMatrix[4] = upX + billboardMatrix[5] = upY + billboardMatrix[6] = upZ + billboardMatrix[7] = 0f + + billboardMatrix[8] = lookX + billboardMatrix[9] = lookY + billboardMatrix[10] = lookZ + billboardMatrix[11] = 0f + + billboardMatrix[12] = 0f + billboardMatrix[13] = 0f + billboardMatrix[14] = 0f + billboardMatrix[15] = 1f + + // First translate to position + Matrix.translateM(modelMatrix, 0, pos.x, pos.y, pos.z) + + // Then apply billboard rotation + val rotatedMatrix = FloatArray(16) + Matrix.multiplyMM(rotatedMatrix, 0, modelMatrix, 0, billboardMatrix, 0) + + // Set the MVP matrix + GLES20.glUniformMatrix4fv(modelMatrixHandle, 1, false, rotatedMatrix, 0) + GLES20.glUniformMatrix4fv(viewMatrixHandle, 1, false, camera.viewMatrix, 0) + GLES20.glUniformMatrix4fv(projMatrixHandle, 1, false, camera.projMatrix, 0) + + // Draw the text + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + + GLES20.glDisable(GLES20.GL_BLEND) + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/navigation/NavigationActions.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/navigation/NavigationActions.kt index ea60f1b89..70fa9c2b2 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/navigation/NavigationActions.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/navigation/NavigationActions.kt @@ -30,6 +30,7 @@ object Route { const val REGISTER = "Register" const val EDIT_IMAGE = "EditImage" const val FULLSCREEN_IMAGE = "fullScreenImage" + const val PLANET_SELECTION = "PlanetSelection" } object Screen { @@ -53,6 +54,7 @@ object Screen { const val REGISTER = "Register Screen" const val EDIT_IMAGE = "Edit Image" const val FULLSCREEN_IMAGE = "fullScreenImage Screen" + const val PLANET_SELECTION = "Planet Selection Screen" } data class TopLevelDestination( diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/overview/Menu.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/overview/Menu.kt index ac7385d43..ebb58bcef 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/overview/Menu.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/overview/Menu.kt @@ -122,6 +122,15 @@ fun MenuScreen(navigationActions: NavigationActions, avatarViewModel: AvatarView style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { navigationActions.navigateTo(Screen.PLANET_SELECTION) }, + modifier = Modifier.fillMaxWidth(0.6f)) { + Text( + text = "Planets", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold) + } } } } diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/PlanetSelectionScreen.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/PlanetSelectionScreen.kt new file mode 100644 index 000000000..f3f587ae8 --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/PlanetSelectionScreen.kt @@ -0,0 +1,96 @@ +package com.github.lookupgroup27.lookup.ui.planetselection + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.White +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 +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.lookupgroup27.lookup.model.planetselection.PlanetSurfaceView +import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions +import com.github.lookupgroup27.lookup.ui.navigation.Screen +import com.github.lookupgroup27.lookup.ui.planetselection.components.PlanetSelectionRow + +/** + * Composable function for the Planet Selection screen. + * + * This screen allows the user to select a planet to view in 3D. + * + * @param viewModel The ViewModel for the Planet Selection screen. + * @param navigationActions The navigation actions to handle screen transitions. + */ +@Composable +fun PlanetSelectionScreen( + viewModel: PlanetSelectionViewModel = viewModel(), + navigationActions: NavigationActions +) { + val planets = viewModel.planets + val selectedPlanet by viewModel.selectedPlanet.collectAsState() + + // Reference to the PlanetSurfaceView to update it + var planetSurfaceView by remember { mutableStateOf(null) } + + Surface( + modifier = Modifier.fillMaxSize(), color = Color.Black // Background color for the screen + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally) { + + // Back Button + IconButton( + onClick = { navigationActions.navigateTo(Screen.MENU) }, + modifier = + Modifier.padding(16.dp) + .align(Alignment.Start) + .testTag("go_back_button_quiz")) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White) + } + + // Top: Horizontal planet selection + PlanetSelectionRow( + planets = planets, onPlanetSelected = { viewModel.selectPlanet(it) }) + + Spacer(modifier = Modifier.height(40.dp)) + + // Middle: Planet name + Text( + text = selectedPlanet.name, + color = White, + fontSize = 50.sp, + fontWeight = FontWeight.Light, + modifier = Modifier.padding(20.dp)) + + // Bottom: Planet renderer + Box( + modifier = Modifier.weight(1f).fillMaxWidth().background(Color.Transparent), + contentAlignment = Alignment.Center) { + AndroidView( + factory = { context -> + PlanetSurfaceView(context, selectedPlanet).also { planetSurfaceView = it } + }, + modifier = Modifier.fillMaxSize()) + } + + // LaunchedEffect to update the planet when selectedPlanet changes + LaunchedEffect(selectedPlanet) { planetSurfaceView?.updatePlanet(selectedPlanet) } + } + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/PlanetSelectionViewModel.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/PlanetSelectionViewModel.kt new file mode 100644 index 000000000..87450e18c --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/PlanetSelectionViewModel.kt @@ -0,0 +1,35 @@ +package com.github.lookupgroup27.lookup.ui.planetselection + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.github.lookupgroup27.lookup.model.location.LocationProviderSingleton +import com.github.lookupgroup27.lookup.model.map.planets.PlanetData +import com.github.lookupgroup27.lookup.model.map.planets.PlanetsRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class PlanetSelectionViewModel(planetsRepository: PlanetsRepository) : ViewModel() { + + private val _selectedPlanet = MutableStateFlow(planetsRepository.planets.first()) + val selectedPlanet: StateFlow = _selectedPlanet + + val planets = planetsRepository.planets // Expose the list of planets + + fun selectPlanet(planet: PlanetData) { + _selectedPlanet.value = planet + } + + companion object { + fun createFactory(context: Context): ViewModelProvider.Factory { + val locationProvider = LocationProviderSingleton.getInstance(context) + val planetsRepository = PlanetsRepository(locationProvider) + return object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return PlanetSelectionViewModel(planetsRepository) as T + } + } + } + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetButton.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetButton.kt new file mode 100644 index 000000000..a7a2b31e1 --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetButton.kt @@ -0,0 +1,42 @@ +package com.github.lookupgroup27.lookup.ui.planetselection.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color.Companion.Black +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.github.lookupgroup27.lookup.model.map.planets.PlanetData + +/** + * Composable function for a planet button. + * + * This button displays an icon representing a planet. + * + * @param planet The planet data to display. + * @param onClick The action to perform when the button is clicked. + */ +@Composable +fun PlanetButton(planet: PlanetData, onClick: () -> Unit) { + Box( + modifier = + Modifier.size(64.dp) + .padding(8.dp) + .background(Black, shape = MaterialTheme.shapes.medium) + .clickable { onClick() }, + contentAlignment = Alignment.Center) { + Image( + painter = painterResource(id = planet.iconRes), + contentDescription = "${planet.name} button", + modifier = Modifier.size(48.dp), + contentScale = ContentScale.Fit) + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetSelectionRow.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetSelectionRow.kt new file mode 100644 index 000000000..e4c8f2055 --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/planetselection/components/PlanetSelectionRow.kt @@ -0,0 +1,30 @@ +package com.github.lookupgroup27.lookup.ui.planetselection.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.lookupgroup27.lookup.model.map.planets.PlanetData + +/** + * Composable function for a row of planet selection buttons. + * + * This row displays a list of planet buttons that the user can click to select a planet. + * + * @param planets The list of planets to display. + * @param onPlanetSelected The action to perform when a planet is selected. + */ +@Composable +fun PlanetSelectionRow(planets: List, onPlanetSelected: (PlanetData) -> Unit) { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + horizontalArrangement = Arrangement.Center) { + items(planets) { planet -> + PlanetButton(planet = planet, onClick = { onPlanetSelected(planet) }) + } + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/post/PostsViewModel.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/post/PostsViewModel.kt index 5ff1db5de..8b3eb18dc 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/post/PostsViewModel.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/post/PostsViewModel.kt @@ -10,26 +10,73 @@ */ package com.github.lookupgroup27.lookup.ui.post +import android.annotation.SuppressLint +import android.content.Context import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.github.lookupgroup27.lookup.model.location.LocationProvider +import com.github.lookupgroup27.lookup.model.location.LocationProviderSingleton import com.github.lookupgroup27.lookup.model.post.Post import com.github.lookupgroup27.lookup.model.post.PostsRepository import com.github.lookupgroup27.lookup.model.post.PostsRepositoryFirestore +import com.github.lookupgroup27.lookup.util.LocationUtils import com.google.firebase.Firebase import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import com.google.firebase.firestore.firestore +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jetbrains.annotations.VisibleForTesting +/** + * ViewModel for managing posts and user interactions in the application. + * + * The `PostsViewModel` serves as the central hub for handling post-related data and logic, acting + * as the intermediary between the user interface (UI) and the [PostsRepository]. It facilitates a + * reactive flow of data to the UI using [StateFlow] and [mutableStateOf], ensuring that the + * application remains responsive and up-to-date. + * + * @constructor Creates a new instance of the `PostsViewModel` with the given [PostsRepository]. + * @property repository The repository responsible for managing posts. + */ class PostsViewModel(private val repository: PostsRepository) : ViewModel() { /** Holds the currently selected post. */ val post = mutableStateOf(null) + init { + repository.init { + auth?.addAuthStateListener(authListener) + getPosts() // to ensure posts are loaded initially + } + } + + @SuppressLint("StaticFieldLeak") private var context: Context? = null + + // LocationProvider instance to get user's current location + private lateinit var locationProvider: LocationProvider + + // Method to initialize context + fun setContext(context: Context) { + this.context = context + locationProvider = context.let { LocationProviderSingleton.getInstance(it) } + // Start monitoring + startLocationMonitoring() + // Start periodic fetching + startPeriodicPostFetching() + } + + // MutableStateFlow to hold the list of nearby posts + private val _nearbyPosts = MutableStateFlow>(emptyList()) + val nearbyPosts: StateFlow> = _nearbyPosts + /** Internal mutable state to hold all posts. */ private val _allPosts = MutableStateFlow>(emptyList()) @@ -63,10 +110,6 @@ class PostsViewModel(private val repository: PostsRepository) : ViewModel() { this.post.value = post } - init { - repository.init { auth?.addAuthStateListener(authListener) } - } - /** * Generates a new unique identifier (UID) for a post. * @@ -150,6 +193,114 @@ class PostsViewModel(private val repository: PostsRepository) : ViewModel() { } } + /** + * Fetches and updates the list of nearby posts based on the user's current location. + * + * This method: + * - Retrieves the user's current location from the [LocationProvider]. + * - Collects the latest list of posts from the [allPosts] flow. + * - Uses the helper function [getSortedNearbyPosts] to calculate distances, sort posts by + * proximity (ascending), and by timestamp (descending). + * - Updates the [_nearbyPosts] flow with the 10 closest posts. + * + * If the user's location is null, the method logs an error and returns without updating posts. If + * the list of posts is empty, the method logs a warning and does not update the flow. + * + * **Coroutines**: + * - Runs on a background thread using `Dispatchers.IO` to ensure non-blocking operation. + * + * @see getSortedNearbyPosts for the sorting and filtering logic. + */ + fun fetchSortedPosts() { + val userLocation = locationProvider.currentLocation.value + if (userLocation == null) { + Log.e("ProximityPostFetcher", "User location is null; cannot fetch nearby posts.") + return + } + + viewModelScope.launch { + allPosts.collect { posts -> + if (posts.isNotEmpty()) { + val sortedNearbyPosts = + getSortedNearbyPosts(posts, userLocation.latitude, userLocation.longitude) + _nearbyPosts.update { sortedNearbyPosts } + } else { + Log.e("fetchPosts", "Posts are empty.") + } + } + } + } + + /** + * Sorts and filters a list of posts based on their distance from the user's location and the time + * they were posted. + * + * This function: + * - Calculates the distance from the user's current location to each post's location. + * - Sorts posts by distance in ascending order (closest posts first). + * - If two posts have the same distance, sorts them by timestamp in descending order (most recent + * posts first). + * - Limits the result to the 10 closest posts. + * + * @param posts The list of [Post] objects to sort and filter. + * @param userLatitude The latitude of the user's current location. + * @param userLongitude The longitude of the user's current location. + * @return A sorted list of [Post] objects containing the 10 closest posts. + */ + internal fun getSortedNearbyPosts( + posts: List, + userLatitude: Double, + userLongitude: Double + ): List { + return posts + .mapNotNull { post -> + val distance = + LocationUtils.calculateDistance( + userLatitude, userLongitude, post.latitude, post.longitude) + post to distance // Pair each post with its calculated distance + } + .sortedWith( + compareBy> { it.second } // Sort by distance (ascending) + .thenByDescending { it.first.timestamp } // Then sort by timestamp (descending) + ) + .take(10) // Take the 10 closest posts + .map { it.first } // Extract only the posts + } + + /** + * Starts monitoring location changes and triggers post updates accordingly. Uses viewModelScope + * to ensure proper coroutine lifecycle management. + */ + private fun startLocationMonitoring() { + viewModelScope.launch { + while (true) { + val location = locationProvider?.currentLocation?.value + if (location != null) { + fetchSortedPosts() + } + delay(1000L) + } + } + } + + /** + * Starts periodic fetching of posts to ensure data stays fresh. Runs in viewModelScope to ensure + * proper lifecycle management. + */ + private fun startPeriodicPostFetching() { + viewModelScope.launch { + while (true) { + fetchSortedPosts() + delay(5000L) + } + } + } + + @VisibleForTesting + fun setLocationProviderForTesting(provider: LocationProvider) { + locationProvider = provider + } + companion object { /** * Factory for creating instances of [PostsViewModel]. diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/register/Register.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/register/Register.kt index 4f0c1a438..7b918780e 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/register/Register.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/register/Register.kt @@ -20,9 +20,9 @@ import com.github.lookupgroup27.lookup.ui.register.components.CustomOutlinedText /** * Composable function representing the registration screen. * - * This screen allows users to create a new account by providing their email, password, and - * confirming their password. It provides real-time validation feedback and handles the registration - * process asynchronously. + * This screen allows users to create a new account by providing their username, email, password, + * and confirming their password. It provides real-time validation feedback and handles the + * registration process asynchronously. * * @param navigationActions The navigation actions used to navigate between screens. * @param viewModel The [RegisterViewModel] managing the registration logic. @@ -36,15 +36,24 @@ fun RegisterScreen( val context = LocalContext.current val uiState by viewModel.uiState.collectAsState() - // Main container for the screen content. Box(modifier = Modifier.fillMaxSize()) { - // Base authentication screen layout. AuthScreen( title = "Create Your Account", onBackClicked = { viewModel.clearFields() navigationActions.navigateTo(Screen.AUTH) }) { + + // Username Input Field + CustomOutlinedTextField( + value = uiState.username, + onValueChange = viewModel::onUsernameChanged, + label = "Username", + errorMessage = uiState.usernameError, + testTag = "username_field") + + Spacer(modifier = Modifier.height(16.dp)) + // Email Input Field CustomOutlinedTextField( value = uiState.email, diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/register/RegisterViewModel.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/register/RegisterViewModel.kt index 60f55d357..282806c9c 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/register/RegisterViewModel.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/register/RegisterViewModel.kt @@ -13,16 +13,15 @@ import kotlinx.coroutines.launch * ViewModel responsible for handling the registration logic and UI state. * * This ViewModel interacts with the [RegisterRepository] to perform registration operations. It - * validates user inputs, updates the UI state accordingly, and handles success and error cases. + * validates user inputs (email, password, confirm password, and username), updates the UI state + * accordingly, and handles success/error cases. * * @property repository The repository handling user registration operations. */ class RegisterViewModel(private val repository: RegisterRepository) : ViewModel() { - // MutableStateFlow to hold and update the UI state. + // Holds and updates the UI state. private val _uiState = MutableStateFlow(RegisterState()) - - // Exposed immutable StateFlow for observing UI state changes. val uiState: StateFlow = _uiState private val EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex() @@ -30,8 +29,6 @@ class RegisterViewModel(private val repository: RegisterRepository) : ViewModel( /** * Companion object providing a custom [ViewModelProvider.Factory] for creating * [RegisterViewModel] instances. - * - * This factory allows the ViewModel to be created with the necessary repository dependency. */ companion object { val Factory: ViewModelProvider.Factory = @@ -72,6 +69,15 @@ class RegisterViewModel(private val repository: RegisterRepository) : ViewModel( } } + /** + * Updates the username in the UI state and clears any related error messages. + * + * @param username The new username input by the user. + */ + fun onUsernameChanged(username: String) { + _uiState.update { it.copy(username = username, usernameError = null, generalError = null) } + } + /** Resets the UI state to its initial values, clearing all input fields and error messages. */ fun clearFields() { _uiState.value = RegisterState() @@ -91,6 +97,24 @@ class RegisterViewModel(private val repository: RegisterRepository) : ViewModel( } } + /** + * Validates the username input. + * + * This method ensures that the username is not empty and matches a certain pattern (alphanumeric + * and underscores are allowed). Adjust as needed for your app's requirements. + * + * @param username The username to validate. + * @return An error message if validation fails; null otherwise. + */ + private fun validateUsername(username: String): String? { + return when { + username.isBlank() -> "Username cannot be empty." + !username.matches("^[A-Za-z0-9_]+$".toRegex()) -> + "Username can only contain letters, digits, and underscores." + else -> null + } + } + /** * Validates the password input. * @@ -124,9 +148,10 @@ class RegisterViewModel(private val repository: RegisterRepository) : ViewModel( /** * Initiates the user registration process. * - * This function performs input validation, updates the UI state with any errors, and proceeds to - * register the user if validation passes. It handles success and error cases, updating the UI - * state accordingly. + * This function: + * 1. Validates all user inputs (email, password, confirm password, username). + * 2. If validation passes, it attempts to register the user through the repository. + * 3. Handles success and various error conditions by updating the UI state accordingly. * * @param onSuccess Callback invoked when registration is successful. */ @@ -134,23 +159,29 @@ class RegisterViewModel(private val repository: RegisterRepository) : ViewModel( val email = _uiState.value.email.trim() val password = _uiState.value.password val confirmPassword = _uiState.value.confirmPassword + val username = _uiState.value.username.trim() - // Perform input validations and collect error messages. + // Perform input validations val emailError = validateEmail(email) val passwordError = validatePassword(password) val confirmPasswordError = validateConfirmPassword(password, confirmPassword) + val usernameError = validateUsername(username) - // Update the UI state with validation errors. + // Update the UI state with validation errors _uiState.update { it.copy( emailError = emailError, passwordError = passwordError, confirmPasswordError = confirmPasswordError, + usernameError = usernameError, generalError = null) } // If any validation errors exist, abort the registration process. - if (emailError != null || passwordError != null || confirmPasswordError != null) { + if (emailError != null || + passwordError != null || + confirmPasswordError != null || + usernameError != null) { return } @@ -160,20 +191,17 @@ class RegisterViewModel(private val repository: RegisterRepository) : ViewModel( // Launch a coroutine to perform the registration asynchronously. viewModelScope.launch { try { - // Attempt to register the user using the repository. - repository.registerUser(email, password) - - // Registration successful; update the UI state and invoke the success callback. + // Attempt to register the user using the repository + repository.registerUser(email, password, username) _uiState.update { it.copy(isLoading = false) } onSuccess() + } catch (e: UsernameAlreadyExistsException) { + _uiState.update { it.copy(isLoading = false, usernameError = e.message) } } catch (e: UserAlreadyExistsException) { - // Specific error when the email is already in use. _uiState.update { it.copy(isLoading = false, generalError = e.message) } } catch (e: WeakPasswordException) { - // Specific error when the password is too weak. _uiState.update { it.copy(isLoading = false, passwordError = e.message) } } catch (e: Exception) { - // General error handling for any other exceptions. _uiState.update { it.copy(isLoading = false, generalError = e.message ?: "An unexpected error occurred.") } diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/util/CelestialObjectsUtils.kt b/app/src/main/java/com/github/lookupgroup27/lookup/util/CelestialObjectsUtils.kt index e96aa4b69..7abffd32f 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/util/CelestialObjectsUtils.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/util/CelestialObjectsUtils.kt @@ -1,4 +1,4 @@ -package com.github.lookupgroup27.lookup.utils +package com.github.lookupgroup27.lookup.util import java.time.ZoneOffset import java.time.ZonedDateTime @@ -7,8 +7,6 @@ import kotlin.math.floor object CelestialObjectsUtils { - private val SCALING_FACTOR = 100 - /** * Converts Right Ascension (RA) from hours to degrees. * @@ -191,9 +189,9 @@ object CelestialObjectsUtils { val azRad = Math.toRadians(azimuth) val altRad = Math.toRadians(altitude) - val x = (SCALING_FACTOR * cos(altRad) * sin(azRad)).toFloat() - val y = (SCALING_FACTOR * cos(altRad) * cos(azRad)).toFloat() - val z = (SCALING_FACTOR * sin(altRad)).toFloat() + val x = (cos(altRad) * sin(azRad)).toFloat() + val y = (cos(altRad) * cos(azRad)).toFloat() + val z = (sin(altRad)).toFloat() return Triple(x, y, z) } diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/util/BufferUtils.kt b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/BufferUtils.kt similarity index 94% rename from app/src/main/java/com/github/lookupgroup27/lookup/util/BufferUtils.kt rename to app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/BufferUtils.kt index 3ab47c48d..f1f8cea6f 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/util/BufferUtils.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/BufferUtils.kt @@ -1,4 +1,4 @@ -package com.github.lookupgroup27.lookup.util +package com.github.lookupgroup27.lookup.util.opengl import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/label/LabelUtils.kt b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/LabelUtils.kt similarity index 71% rename from app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/label/LabelUtils.kt rename to app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/LabelUtils.kt index 85e98912d..65c0d706e 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/map/renderables/label/LabelUtils.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/LabelUtils.kt @@ -1,4 +1,4 @@ -package com.github.lookupgroup27.lookup.model.map.renderables.label +package com.github.lookupgroup27.lookup.util.opengl import android.graphics.Bitmap import android.graphics.Canvas @@ -15,19 +15,19 @@ object LabelUtils { */ fun createLabelBitmap(text: String): Bitmap { val width = 256 // Width of the bitmap - val height = 128 // Height of the bitmap + val height = 256 // Height of the bitmap val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - canvas.drawColor(android.graphics.Color.BLACK) // Background color - val paint = Paint() paint.color = android.graphics.Color.WHITE // Text color paint.textSize = 32f paint.isAntiAlias = true - val x = 20f - val y = height / 2f + val x = width / 2f - paint.measureText(text) / 2 + val y = height / 2f - (paint.descent() + paint.ascent()) / 2 + + val canvas = Canvas(bitmap) + canvas.drawColor(android.graphics.Color.TRANSPARENT) // Background color canvas.drawText(text, x, y, paint) return bitmap diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/Position.kt b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/Position.kt new file mode 100644 index 000000000..5e206a728 --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/Position.kt @@ -0,0 +1,3 @@ +package com.github.lookupgroup27.lookup.util.opengl + +data class Position(val x: Float, val y: Float, val z: Float) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/ShaderProgram.kt b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/ShaderProgram.kt index f21e9287c..114ae37ea 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/ShaderProgram.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/ShaderProgram.kt @@ -37,7 +37,7 @@ class ShaderProgram(vertexShaderCode: String, fragmentShaderCode: String) { if (compileStatus[0] == 0) { Log.e(TAG, "Error compiling shader: ${GLES20.glGetShaderInfoLog(shaderId)}") GLES20.glDeleteShader(shaderId) - throw RuntimeException("Shader compilation failed") + throw RuntimeException("Shader compilation failed:") } return shaderId } diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/util/ShaderUtils.kt b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/ShaderUtils.kt similarity index 92% rename from app/src/main/java/com/github/lookupgroup27/lookup/util/ShaderUtils.kt rename to app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/ShaderUtils.kt index 119c81e54..c895882a8 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/util/ShaderUtils.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/util/opengl/ShaderUtils.kt @@ -1,4 +1,4 @@ -package com.github.lookupgroup27.lookup.util +package com.github.lookupgroup27.lookup.util.opengl import android.content.Context import android.util.Log diff --git a/app/src/main/res/drawable/jupiter_icon.png b/app/src/main/res/drawable/jupiter_icon.png new file mode 100644 index 000000000..86bfb890d Binary files /dev/null and b/app/src/main/res/drawable/jupiter_icon.png differ diff --git a/app/src/main/res/drawable/mars_icon.png b/app/src/main/res/drawable/mars_icon.png new file mode 100644 index 000000000..f77f59a95 Binary files /dev/null and b/app/src/main/res/drawable/mars_icon.png differ diff --git a/app/src/main/res/drawable/mercury_icon.png b/app/src/main/res/drawable/mercury_icon.png new file mode 100644 index 000000000..a6c7c2f37 Binary files /dev/null and b/app/src/main/res/drawable/mercury_icon.png differ diff --git a/app/src/main/res/drawable/moon_icon.png b/app/src/main/res/drawable/moon_icon.png new file mode 100644 index 000000000..d4ec8a410 Binary files /dev/null and b/app/src/main/res/drawable/moon_icon.png differ diff --git a/app/src/main/res/drawable/neptune_icon.png b/app/src/main/res/drawable/neptune_icon.png new file mode 100644 index 000000000..59732deb4 Binary files /dev/null and b/app/src/main/res/drawable/neptune_icon.png differ diff --git a/app/src/main/res/drawable/no_images_placeholder.png b/app/src/main/res/drawable/no_images_placeholder.png new file mode 100644 index 000000000..b74471737 Binary files /dev/null and b/app/src/main/res/drawable/no_images_placeholder.png differ diff --git a/app/src/main/res/drawable/saturn_icon.png b/app/src/main/res/drawable/saturn_icon.png new file mode 100644 index 000000000..654273ca2 Binary files /dev/null and b/app/src/main/res/drawable/saturn_icon.png differ diff --git a/app/src/main/res/drawable/uranus_icon.png b/app/src/main/res/drawable/uranus_icon.png new file mode 100644 index 000000000..47fce26ca Binary files /dev/null and b/app/src/main/res/drawable/uranus_icon.png differ diff --git a/app/src/main/res/drawable/venus_icon.png b/app/src/main/res/drawable/venus_icon.png new file mode 100644 index 000000000..3f1edcd2e Binary files /dev/null and b/app/src/main/res/drawable/venus_icon.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 051cd0bf1..3aec47864 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,4 +24,5 @@ Reset map_slider_tag Nearby Stargazers + No images are available \ No newline at end of file diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/model/map/MapRendererTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/model/map/MapRendererTest.kt new file mode 100644 index 000000000..f811cf04d --- /dev/null +++ b/app/src/test/java/com/github/lookupgroup27/lookup/model/map/MapRendererTest.kt @@ -0,0 +1,40 @@ +package com.github.lookupgroup27.lookup.model.map + +import android.content.Context +import com.github.lookupgroup27.lookup.model.map.planets.PlanetsRepository +import com.github.lookupgroup27.lookup.model.map.stars.StarDataRepository +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock + +class MapRendererTest { + + private lateinit var mapRenderer: MapRenderer + + @Before + fun setUp() { + // Initialize MapRenderer with a mock or test context + val mockContext = mock(Context::class.java) + val mockStarDataRepository = mock(StarDataRepository::class.java) + val mockPlanetsRepository = mock(PlanetsRepository::class.java) + mapRenderer = MapRenderer(mockContext, mockStarDataRepository, mockPlanetsRepository, 45f) + } + + @Test + fun `computeDeltaTime should return correct delta time`() { + // Arrange: Inject a fixed time provider + var fakeTime = 1000L + mapRenderer.timeProvider = { fakeTime } + + // Simulate first frame + mapRenderer.computeDeltaTime() // Initializes lastFrameTime + + // Act: Advance time and compute deltaTime + fakeTime += 200 // Simulate 200 milliseconds later + val deltaTime = mapRenderer.computeDeltaTime() + + // Assert + assertEquals("Delta time should be 0.2 seconds", 0.2f, deltaTime, 1e-6f) + } +} diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/model/map/renderables/MoonTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/model/map/renderables/MoonTest.kt new file mode 100644 index 000000000..a118f64b1 --- /dev/null +++ b/app/src/test/java/com/github/lookupgroup27/lookup/model/map/renderables/MoonTest.kt @@ -0,0 +1,46 @@ +import com.github.lookupgroup27.lookup.R +import com.github.lookupgroup27.lookup.model.map.renderables.Moon +import java.util.Calendar +import java.util.TimeZone +import org.junit.Assert.assertEquals +import org.junit.Test + +class MoonTest { + + @Test + fun testGetMoonPhaseTextureId() { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + + calendar.set(2024, Calendar.JULY, 6) + var textureId = Moon.getMoonPhaseTextureId(calendar) + assertEquals(R.drawable.new_moon, textureId) + + calendar.set(2024, Calendar.AUGUST, 9) + textureId = Moon.getMoonPhaseTextureId(calendar) + assertEquals(R.drawable.waxing_crescent, textureId) + + calendar.set(2024, Calendar.MAY, 15) + textureId = Moon.getMoonPhaseTextureId(calendar) + assertEquals(R.drawable.first_quarter, textureId) + + calendar.set(2021, Calendar.AUGUST, 19) + textureId = Moon.getMoonPhaseTextureId(calendar) + assertEquals(R.drawable.waxing_gibbous, textureId) + + calendar.set(2025, Calendar.JANUARY, 13) + textureId = Moon.getMoonPhaseTextureId(calendar) + assertEquals(R.drawable.full_moon, textureId) + + calendar.set(2023, Calendar.OCTOBER, 2) + textureId = Moon.getMoonPhaseTextureId(calendar) + assertEquals(R.drawable.waning_gibbous, textureId) + + calendar.set(2024, Calendar.JANUARY, 3) + textureId = Moon.getMoonPhaseTextureId(calendar) + assertEquals(R.drawable.last_quarter, textureId) + + calendar.set(2021, Calendar.MAY, 8) + textureId = Moon.getMoonPhaseTextureId(calendar) + assertEquals(R.drawable.waning_crescent, textureId) + } +} diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/model/map/renderables/PlanetTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/model/map/renderables/PlanetTest.kt index c823d9984..5998266d9 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/model/map/renderables/PlanetTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/model/map/renderables/PlanetTest.kt @@ -126,4 +126,75 @@ class PlanetTest { // Assert assertTrue("Ray should intersect even when the origin is inside the sphere", intersects) } + + @Test + fun `updateRotation should update rotationAngle based on deltaTime and rotationSpeed`() { + // Arrange + val planet = + Planet( + context = context, + name = "TestPlanet", + position = floatArrayOf(0f, 0f, -5f), + textureId = R.drawable.planet_texture, + scale = 1f) + + val initialRotationAngle = 0f + val rotationSpeed = 30f // degrees per second + val deltaTime = 1f // 1 second + + // Set rotationSpeed manually for testing (assuming rotationSpeed is accessible or made + // testable) + val rotationSpeedField = planet.javaClass.getDeclaredField("rotationSpeed") + rotationSpeedField.isAccessible = true + rotationSpeedField.setFloat(planet, rotationSpeed) + + // Act + planet.updateRotation(deltaTime) + + // Assert + val expectedRotationAngle = (initialRotationAngle + rotationSpeed * deltaTime) % 360f + val rotationAngleField = planet.javaClass.getDeclaredField("rotationAngle") + rotationAngleField.isAccessible = true + val actualRotationAngle = rotationAngleField.getFloat(planet) + + assertTrue( + "Rotation angle should be updated correctly", actualRotationAngle == expectedRotationAngle) + } + + @Test + fun `updateRotation should wrap rotationAngle around 360 degrees`() { + // Arrange + val planet = + Planet( + context = context, + name = "TestPlanet", + position = floatArrayOf(0f, 0f, -5f), + textureId = R.drawable.planet_texture, + scale = 1f) + + val initialRotationAngle = 350f + val rotationSpeed = 30f // degrees per second + val deltaTime = 1f // 1 second + + // Set rotationSpeed manually for testing + val rotationSpeedField = planet.javaClass.getDeclaredField("rotationSpeed") + rotationSpeedField.isAccessible = true + rotationSpeedField.setFloat(planet, rotationSpeed) + + // Manually set the initial rotationAngle + val rotationAngleField = planet.javaClass.getDeclaredField("rotationAngle") + rotationAngleField.isAccessible = true + rotationAngleField.setFloat(planet, initialRotationAngle) + + // Act + planet.updateRotation(deltaTime) + + // Assert + val expectedRotationAngle = (initialRotationAngle + rotationSpeed * deltaTime) % 360f + val actualRotationAngle = rotationAngleField.getFloat(planet) + + assertTrue( + "Rotation angle should wrap around correctly at 360 degrees", + actualRotationAngle == expectedRotationAngle) + } } diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/model/starData/StarDataRepositoryTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/model/map/stars/StarDataRepositoryTest.kt similarity index 98% rename from app/src/test/java/com/github/lookupgroup27/lookup/model/starData/StarDataRepositoryTest.kt rename to app/src/test/java/com/github/lookupgroup27/lookup/model/map/stars/StarDataRepositoryTest.kt index 9fe16ea7f..342e0e6a7 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/model/starData/StarDataRepositoryTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/model/map/stars/StarDataRepositoryTest.kt @@ -5,7 +5,7 @@ import android.content.res.AssetManager import android.location.Location import androidx.test.core.app.ApplicationProvider import com.github.lookupgroup27.lookup.model.location.LocationProvider -import com.github.lookupgroup27.lookup.utils.CelestialObjectsUtils +import com.github.lookupgroup27.lookup.util.CelestialObjectsUtils import com.google.firebase.FirebaseApp import java.io.ByteArrayInputStream import java.io.InputStream diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/model/planets/PlanetsRepositoryTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/model/planets/PlanetsRepositoryTest.kt index 9b8a09044..2eb7d64d5 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/model/planets/PlanetsRepositoryTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/model/planets/PlanetsRepositoryTest.kt @@ -3,6 +3,7 @@ import android.location.Location import androidx.test.core.app.ApplicationProvider import com.github.lookupgroup27.lookup.model.location.LocationProvider import com.github.lookupgroup27.lookup.model.map.planets.PlanetData +import com.github.lookupgroup27.lookup.model.map.planets.PlanetsRepository import com.google.firebase.FirebaseApp import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetRendererTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetRendererTest.kt new file mode 100644 index 000000000..8e842f68d --- /dev/null +++ b/app/src/test/java/com/github/lookupgroup27/lookup/model/planetselection/PlanetRendererTest.kt @@ -0,0 +1,41 @@ +package com.github.lookupgroup27.lookup.model.planetselection + +import android.content.Context +import com.github.lookupgroup27.lookup.model.map.planets.PlanetData +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.* + +class PlanetRendererTest { + + private lateinit var context: Context + private lateinit var planetData: PlanetData + private lateinit var renderer: PlanetRenderer + + @Before + fun setUp() { + // Mock context and PlanetData + context = mock(Context::class.java) + planetData = PlanetData(name = "Mars", "301", iconRes = 0, textureId = 1) + + // Initialize PlanetRenderer with mocked context and planetData + renderer = PlanetRenderer(context, planetData) + } + + @Test + fun `updatePlanet updates planetData and reinitializes Planet`() { + // New PlanetData to update + val newPlanetData = PlanetData(name = "Mars", "301", iconRes = 1, textureId = 2) + + // Call updatePlanet + renderer.updatePlanet(newPlanetData) + + // Verify that planetData is updated + val privatePlanetDataField = renderer.javaClass.getDeclaredField("planetData") + privatePlanetDataField.isAccessible = true + val updatedPlanetData = privatePlanetDataField.get(renderer) as PlanetData + + assert(updatedPlanetData.name == "Mars") { "PlanetData name mismatch after update" } + assert(updatedPlanetData.textureId == 2) { "PlanetData texture ID mismatch after update" } + } +} diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/model/register/RegisterRepositoryFirestoreTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/model/register/RegisterRepositoryFirestoreTest.kt index 69d0671a3..e6a7b9da2 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/model/register/RegisterRepositoryFirestoreTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/model/register/RegisterRepositoryFirestoreTest.kt @@ -1,13 +1,16 @@ package com.github.lookupgroup27.lookup.model.register +import androidx.test.core.app.ApplicationProvider import com.google.android.gms.tasks.Tasks import com.google.firebase.FirebaseApp import com.google.firebase.auth.* -import kotlinx.coroutines.runBlocking +import com.google.firebase.firestore.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.* +import org.junit.* import org.junit.Assert.* -import org.junit.Before -import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.* import org.mockito.Mockito.* import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner @@ -17,64 +20,100 @@ class RegisterRepositoryFirestoreTest { private lateinit var repository: RegisterRepositoryFirestore private lateinit var mockAuth: FirebaseAuth + private lateinit var mockFirestore: FirebaseFirestore + private lateinit var mockCollectionRef: CollectionReference + private lateinit var mockQuery: Query + + private val testDispatcher = StandardTestDispatcher() @Before fun setUp() { + Dispatchers.setMain(testDispatcher) MockitoAnnotations.openMocks(this) - if (FirebaseApp.getApps(androidx.test.core.app.ApplicationProvider.getApplicationContext()) - .isEmpty()) { - FirebaseApp.initializeApp(androidx.test.core.app.ApplicationProvider.getApplicationContext()) + if (FirebaseApp.getApps(ApplicationProvider.getApplicationContext()).isEmpty()) { + FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext()) } mockAuth = mock(FirebaseAuth::class.java) + mockFirestore = mock(FirebaseFirestore::class.java) + mockCollectionRef = mock(CollectionReference::class.java) + mockQuery = mock(Query::class.java) + + // For any username passed to whereEqualTo, return mockQuery + `when`(mockFirestore.collection("users")).thenReturn(mockCollectionRef) + `when`(mockCollectionRef.whereEqualTo(eq("username"), anyString())).thenReturn(mockQuery) + `when`(mockQuery.limit(1)).thenReturn(mockQuery) - repository = RegisterRepositoryFirestore(mockAuth) + repository = RegisterRepositoryFirestore(mockAuth, mockFirestore) + } + + @After + fun tearDown() { + Dispatchers.resetMain() } @Test - fun `registerUser succeeds with valid credentials`() = runBlocking { + fun `registerUser throws UsernameAlreadyExistsException when username is taken`() = runTest { val mockResult = mock(AuthResult::class.java) + val mockQuerySnapshot = mock(QuerySnapshot::class.java) + val mockDoc = mock(DocumentSnapshot::class.java) + + // Query should not be empty for "takenUsername" + `when`(mockQuerySnapshot.isEmpty).thenReturn(false) + `when`(mockQuerySnapshot.documents).thenReturn(listOf(mockDoc)) + `when`(mockQuery.get()).thenReturn(Tasks.forResult(mockQuerySnapshot)) + `when`(mockAuth.createUserWithEmailAndPassword("test@example.com", "Password123")) .thenReturn(Tasks.forResult(mockResult)) try { - repository.registerUser("test@example.com", "Password123") - assertTrue(true) + repository.registerUser("test@example.com", "Password123", "takenUsername") + fail("Expected UsernameAlreadyExistsException to be thrown") + } catch (e: UsernameAlreadyExistsException) { + assertEquals("Username 'takenUsername' is already in use.", e.message) } catch (e: Exception) { - fail("Expected registration to succeed, but it failed with exception: ${e.message}") + fail("Expected UsernameAlreadyExistsException, but got ${e::class.simpleName}") } } @Test - fun `registerUser throws UserAlreadyExistsException when email is already in use`() = - runBlocking { - val exception = - FirebaseAuthUserCollisionException( - "ERROR_EMAIL_ALREADY_IN_USE", - "The email address is already in use by another account.") - `when`(mockAuth.createUserWithEmailAndPassword("test@example.com", "Password123")) - .thenReturn(Tasks.forException(exception)) - - try { - repository.registerUser("test@example.com", "Password123") - fail("Expected UserAlreadyExistsException to be thrown") - } catch (e: UserAlreadyExistsException) { - assertEquals("An account with this email already exists.", e.message) - } catch (e: Exception) { - fail("Expected UserAlreadyExistsException, but got ${e::class.simpleName}") - } - } + fun `registerUser throws UserAlreadyExistsException when email is already in use`() = runTest { + val mockQuerySnapshot = mock(QuerySnapshot::class.java) + `when`(mockQuerySnapshot.isEmpty).thenReturn(true) + `when`(mockQuery.get()).thenReturn(Tasks.forResult(mockQuerySnapshot)) + + val exception = + FirebaseAuthUserCollisionException( + "ERROR_EMAIL_ALREADY_IN_USE", "The email address is already in use by another account.") + + `when`(mockAuth.createUserWithEmailAndPassword("test@example.com", "Password123")) + .thenReturn(Tasks.forException(exception)) + + try { + repository.registerUser("test@example.com", "Password123", "anotherUniqueUsername") + fail("Expected UserAlreadyExistsException to be thrown") + } catch (e: UserAlreadyExistsException) { + assertEquals("An account with this email already exists.", e.message) + } catch (e: Exception) { + fail("Expected UserAlreadyExistsException, but got ${e::class.simpleName}") + } + } @Test - fun `registerUser throws WeakPasswordException when password is weak`() = runBlocking { + fun `registerUser throws WeakPasswordException when password is weak`() = runTest { + val mockQuerySnapshot = mock(QuerySnapshot::class.java) + `when`(mockQuerySnapshot.isEmpty).thenReturn(true) + `when`(mockQuery.get()).thenReturn(Tasks.forResult(mockQuerySnapshot)) + val exception = FirebaseAuthWeakPasswordException("ERROR_WEAK_PASSWORD", "Password is too weak", null) + `when`(mockAuth.createUserWithEmailAndPassword("test@example.com", "weakpass")) .thenReturn(Tasks.forException(exception)) try { - repository.registerUser("test@example.com", "weakpass") + repository.registerUser("test@example.com", "weakpass", "weakPassUsername") fail("Expected WeakPasswordException to be thrown") } catch (e: WeakPasswordException) { assertEquals("Your password is too weak.", e.message) @@ -84,13 +123,18 @@ class RegisterRepositoryFirestoreTest { } @Test - fun `registerUser throws Exception for unknown errors`() = runBlocking { + fun `registerUser throws Exception for unknown errors`() = runTest { + val mockQuerySnapshot = mock(QuerySnapshot::class.java) + `when`(mockQuerySnapshot.isEmpty).thenReturn(true) + `when`(mockQuery.get()).thenReturn(Tasks.forResult(mockQuerySnapshot)) + val exception = Exception("Unknown error") + `when`(mockAuth.createUserWithEmailAndPassword("test@example.com", "Password123")) .thenReturn(Tasks.forException(exception)) try { - repository.registerUser("test@example.com", "Password123") + repository.registerUser("test@example.com", "Password123", "errorUsername") fail("Expected Exception to be thrown") } catch (e: Exception) { assertEquals("Registration failed due to an unexpected error.", e.message) diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/ui/map/MapViewModelTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/ui/map/MapViewModelTest.kt index 4a4080799..4d5b3c28a 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/ui/map/MapViewModelTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/ui/map/MapViewModelTest.kt @@ -1,12 +1,12 @@ package com.github.lookupgroup27.lookup.ui.map -import PlanetsRepository import android.content.Context import android.content.pm.ActivityInfo import android.hardware.Sensor import android.hardware.SensorManager import android.view.ScaleGestureDetector import androidx.activity.ComponentActivity +import com.github.lookupgroup27.lookup.model.map.planets.PlanetsRepository import com.github.lookupgroup27.lookup.model.map.stars.StarDataRepository import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/model/feed/ProximityAndTimePostFetcherTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/ui/post/PostsViewModelSortingTest.kt similarity index 71% rename from app/src/test/java/com/github/lookupgroup27/lookup/model/feed/ProximityAndTimePostFetcherTest.kt rename to app/src/test/java/com/github/lookupgroup27/lookup/ui/post/PostsViewModelSortingTest.kt index 092012996..f1c2b2cf2 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/model/feed/ProximityAndTimePostFetcherTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/ui/post/PostsViewModelSortingTest.kt @@ -1,4 +1,4 @@ -package com.github.lookupgroup27.lookup.model.feed +package com.github.lookupgroup27.lookup.ui.post import android.content.Context import android.location.Location @@ -7,7 +7,6 @@ import com.github.lookupgroup27.lookup.model.location.LocationProvider import com.github.lookupgroup27.lookup.model.post.Post import com.github.lookupgroup27.lookup.model.post.PostsRepository import com.github.lookupgroup27.lookup.model.post.PostsRepositoryFirestore -import com.github.lookupgroup27.lookup.ui.post.PostsViewModel import com.google.firebase.FirebaseApp import com.google.firebase.firestore.CollectionReference import com.google.firebase.firestore.FirebaseFirestore @@ -15,6 +14,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.* import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -26,16 +26,16 @@ import org.mockito.kotlin.any import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +/** Unit tests for sorting functionality in the PostsViewModel. */ @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -class ProximityAndTimePostFetcherTest { +class PostsViewModelSortingTest { @Mock private lateinit var mockFirestore: FirebaseFirestore @Mock private lateinit var mockCollectionReference: CollectionReference private lateinit var postsRepositoryFirestore: PostsRepositoryFirestore private lateinit var postsViewModel: PostsViewModel - private lateinit var proximityAndTimePostFetcher: ProximityAndTimePostFetcher private lateinit var context: Context private lateinit var locationProvider: TestLocationProvider private lateinit var postsRepository: PostsRepository @@ -54,9 +54,10 @@ class ProximityAndTimePostFetcherTest { `when`(mockFirestore.collection(any())).thenReturn(mockCollectionReference) postsRepositoryFirestore = PostsRepositoryFirestore(mockFirestore) postsViewModel = PostsViewModel(postsRepositoryFirestore) - - proximityAndTimePostFetcher = ProximityAndTimePostFetcher(postsViewModel, context) locationProvider = TestLocationProvider() + + // Initialize the ViewModel with context + postsViewModel.setContext(context) } @After @@ -69,14 +70,17 @@ class ProximityAndTimePostFetcherTest { // Simulate location as null locationProvider.setLocation(null, null) + // Set the locationProvider in ViewModel + postsViewModel.setLocationProviderForTesting(locationProvider) + // Call the function - proximityAndTimePostFetcher.fetchSortedPosts() + postsViewModel.fetchSortedPosts() // Wait for any asynchronous updates testScheduler.advanceUntilIdle() // Assert that no nearby posts are returned when location is null - assertTrue(proximityAndTimePostFetcher.nearbyPosts.value.isEmpty()) + assertTrue(postsViewModel.nearbyPosts.value.isEmpty()) } @Test @@ -84,19 +88,42 @@ class ProximityAndTimePostFetcherTest { // Set a valid location locationProvider.setLocation(37.7749, -122.4194) // San Francisco coordinates + // Set the locationProvider in ViewModel + postsViewModel.setLocationProviderForTesting(locationProvider) + // Simulate empty list of posts in the repository whenever(postsRepository.getPosts(any(), any())).thenAnswer { (it.arguments[0] as (List?) -> Unit).invoke(emptyList()) } // Fetch posts - proximityAndTimePostFetcher.fetchSortedPosts() + postsViewModel.fetchSortedPosts() // Wait for asynchronous updates testScheduler.advanceUntilIdle() // Assert that no nearby posts are returned when there are no posts in the repository - assertTrue(proximityAndTimePostFetcher.nearbyPosts.value.isEmpty()) + assertTrue(postsViewModel.nearbyPosts.value.isEmpty()) + } + + @Test + fun `getSortedNearbyPosts sorts posts correctly`() { + // Arrange + val post1 = Post("Post 1", latitude = 37.7750, longitude = -122.4195, timestamp = 1000) + val post2 = Post("Post 2", latitude = 37.7755, longitude = -122.4190, timestamp = 2000) + val post3 = Post("Post 3", latitude = 37.7760, longitude = -122.4185, timestamp = 3000) + val posts = listOf(post3, post2, post1) + + val userLatitude = 37.7749 + val userLongitude = -122.4194 + + // Act + val sortedPosts = postsViewModel.getSortedNearbyPosts(posts, userLatitude, userLongitude) + + // Assert + assertEquals(post1, sortedPosts[0]) // Closest post + assertEquals(post2, sortedPosts[1]) // Second closest + assertEquals(post3, sortedPosts[2]) // Third closest } /** TestLocationProvider allows for manual setting of location values. */ diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/ui/register/RegisterViewModelTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/ui/register/RegisterViewModelTest.kt index f757bbddf..bdc6bb4e2 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/ui/register/RegisterViewModelTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/ui/register/RegisterViewModelTest.kt @@ -1,238 +1,309 @@ package com.github.lookupgroup27.lookup.ui.register -import com.github.lookupgroup27.lookup.model.register.RegisterRepository -import com.github.lookupgroup27.lookup.model.register.UserAlreadyExistsException -import com.github.lookupgroup27.lookup.model.register.WeakPasswordException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi +import com.github.lookupgroup27.lookup.model.register.* +import kotlinx.coroutines.* import kotlinx.coroutines.test.* -import org.junit.After +import org.junit.* import org.junit.Assert.* import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 -@OptIn(ExperimentalCoroutinesApi::class) -class RegisterViewModelTest { +/** + * A fake repository used for testing, allowing us to control the behavior of the registration + * process. + */ +class MockRegisterRepository : RegisterRepository { + var shouldThrowUsernameExistsException = false + var shouldThrowUserAlreadyExistsException = false + var shouldThrowWeakPasswordException = false + var shouldThrowGenericException = false - private lateinit var viewModel: RegisterViewModel - private lateinit var mockRepository: MockRegisterRepository + override suspend fun registerUser(email: String, password: String, username: String) { + when { + shouldThrowUsernameExistsException -> + throw UsernameAlreadyExistsException("Username '$username' is already in use.") + shouldThrowUserAlreadyExistsException -> + throw UserAlreadyExistsException("An account with this email already exists.") + shouldThrowWeakPasswordException -> throw WeakPasswordException("Your password is too weak.") + shouldThrowGenericException -> + throw Exception("Registration failed due to an unexpected error.") + else -> { + // Success scenario: do nothing + } + } + } +} + +@RunWith(JUnit4::class) +class RegisterViewModelTest { private val testDispatcher = StandardTestDispatcher() + private lateinit var viewModel: RegisterViewModel + private lateinit var fakeRepository: MockRegisterRepository + @Before - fun setUp() { + fun setup() { + // Set the main dispatcher for the tests. Dispatchers.setMain(testDispatcher) - mockRepository = MockRegisterRepository() - viewModel = RegisterViewModel(mockRepository) + + fakeRepository = MockRegisterRepository() + viewModel = RegisterViewModel(fakeRepository) } @After - fun tearDown() { + fun teardown() { + // Reset the main dispatcher to the original state. Dispatchers.resetMain() } @Test - fun `onEmailChanged updates email state`() { - viewModel.onEmailChanged("test@example.com") - assertEquals("test@example.com", viewModel.uiState.value.email) + fun `initial state is empty and not loading`() { + val state = viewModel.uiState.value + assertEquals("", state.email) + assertEquals("", state.password) + assertEquals("", state.confirmPassword) + assertEquals("", state.username) + assertFalse(state.isLoading) + assertNull(state.emailError) + assertNull(state.passwordError) + assertNull(state.confirmPasswordError) + assertNull(state.usernameError) + assertNull(state.generalError) } @Test - fun `onPasswordChanged updates password state`() { - viewModel.onPasswordChanged("Password123") - assertEquals("Password123", viewModel.uiState.value.password) + fun `onEmailChanged updates email and clears errors`() { + viewModel.onEmailChanged("test@example.com") + val state = viewModel.uiState.value + assertEquals("test@example.com", state.email) + assertNull(state.emailError) + assertNull(state.generalError) } @Test - fun `onConfirmPasswordChanged updates confirmPassword state`() { - viewModel.onConfirmPasswordChanged("Password123") - assertEquals("Password123", viewModel.uiState.value.confirmPassword) + fun `onPasswordChanged updates password and clears errors`() { + viewModel.onPasswordChanged("Password123") + val state = viewModel.uiState.value + assertEquals("Password123", state.password) + assertNull(state.passwordError) + assertNull(state.generalError) } @Test - fun `clearFields resets state`() { - viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("Password123") + fun `onConfirmPasswordChanged updates confirmPassword and clears errors`() { viewModel.onConfirmPasswordChanged("Password123") - viewModel.clearFields() - assertEquals("", viewModel.uiState.value.email) - assertEquals("", viewModel.uiState.value.password) - assertEquals("", viewModel.uiState.value.confirmPassword) + val state = viewModel.uiState.value + assertEquals("Password123", state.confirmPassword) + assertNull(state.confirmPasswordError) + assertNull(state.generalError) } - // Validation Tests via Public Interface - @Test - fun `registerUser sets emailError when email is blank`() = runTest { - viewModel.onEmailChanged("") - viewModel.onPasswordChanged("Password1") - viewModel.onConfirmPasswordChanged("Password1") - - viewModel.registerUser {} - - assertEquals("Email cannot be empty.", viewModel.uiState.value.emailError) - assertNull(viewModel.uiState.value.passwordError) - assertNull(viewModel.uiState.value.confirmPasswordError) - assertFalse(viewModel.uiState.value.isLoading) + fun `onUsernameChanged updates username and clears errors`() { + viewModel.onUsernameChanged("uniqueUsername") + val state = viewModel.uiState.value + assertEquals("uniqueUsername", state.username) + assertNull(state.usernameError) + assertNull(state.generalError) } @Test - fun `registerUser sets emailError when email is invalid`() = runTest { - viewModel.onEmailChanged("invalid-email") - viewModel.onPasswordChanged("Password1") - viewModel.onConfirmPasswordChanged("Password1") - - viewModel.registerUser {} - - assertEquals("Invalid email address.", viewModel.uiState.value.emailError) - assertNull(viewModel.uiState.value.passwordError) - assertNull(viewModel.uiState.value.confirmPasswordError) - assertFalse(viewModel.uiState.value.isLoading) - } + fun `registerUser with valid inputs and success scenario updates state and calls onSuccess`() = + runTest { + viewModel.onUsernameChanged("uniqueUsername") + viewModel.onEmailChanged("test@example.com") + viewModel.onPasswordChanged("Password123") + viewModel.onConfirmPasswordChanged("Password123") + + var successCalled = false + viewModel.registerUser { successCalled = true } + + // Advance until all coroutines have finished + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertTrue(successCalled) + assertNull(state.generalError) + assertNull(state.usernameError) + assertNull(state.emailError) + assertNull(state.passwordError) + assertNull(state.confirmPasswordError) + } @Test - fun `registerUser sets passwordError when password is too short`() = runTest { + fun `registerUser shows validation error when username is empty`() = runTest { + // Username not entered viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("Pass1") - viewModel.onConfirmPasswordChanged("Pass1") + viewModel.onPasswordChanged("Password123") + viewModel.onConfirmPasswordChanged("Password123") - viewModel.registerUser {} + var successCalled = false + viewModel.registerUser { successCalled = true } - assertEquals("Password must be at least 8 characters.", viewModel.uiState.value.passwordError) - assertNull(viewModel.uiState.value.emailError) - assertNull(viewModel.uiState.value.confirmPasswordError) - assertFalse(viewModel.uiState.value.isLoading) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(successCalled) + assertEquals("Username cannot be empty.", state.usernameError) } @Test - fun `registerUser sets passwordError when password lacks digit`() = runTest { - viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("Password") - viewModel.onConfirmPasswordChanged("Password") + fun `registerUser shows validation error when email is invalid`() = runTest { + viewModel.onUsernameChanged("uniqueUsername") + viewModel.onEmailChanged("invalidEmail") + viewModel.onPasswordChanged("Password123") + viewModel.onConfirmPasswordChanged("Password123") - viewModel.registerUser {} + var successCalled = false + viewModel.registerUser { successCalled = true } - assertEquals( - "Password must include at least one number.", viewModel.uiState.value.passwordError) - assertNull(viewModel.uiState.value.emailError) - assertNull(viewModel.uiState.value.confirmPasswordError) - assertFalse(viewModel.uiState.value.isLoading) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(successCalled) + assertEquals("Invalid email address.", state.emailError) } @Test - fun `registerUser sets passwordError when password lacks uppercase letter`() = runTest { + fun `registerUser shows validation error when passwords do not match`() = runTest { + viewModel.onUsernameChanged("uniqueUsername") viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("password1") - viewModel.onConfirmPasswordChanged("password1") + viewModel.onPasswordChanged("Password123") + viewModel.onConfirmPasswordChanged("WrongPassword") - viewModel.registerUser {} + var successCalled = false + viewModel.registerUser { successCalled = true } - assertEquals( - "Password must include at least one uppercase letter.", - viewModel.uiState.value.passwordError) - assertNull(viewModel.uiState.value.emailError) - assertNull(viewModel.uiState.value.confirmPasswordError) - assertFalse(viewModel.uiState.value.isLoading) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(successCalled) + assertEquals("Passwords do not match.", state.confirmPasswordError) } @Test - fun `registerUser sets confirmPasswordError when passwords do not match`() = runTest { + fun `registerUser shows validation error when password is weak`() = runTest { + viewModel.onUsernameChanged("uniqueUsername") viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("Password1") - viewModel.onConfirmPasswordChanged("Password2") + viewModel.onPasswordChanged("pass") // too short + viewModel.onConfirmPasswordChanged("pass") + + var successCalled = false + viewModel.registerUser { successCalled = true } - viewModel.registerUser {} + advanceUntilIdle() - assertEquals("Passwords do not match.", viewModel.uiState.value.confirmPasswordError) - assertNull(viewModel.uiState.value.emailError) - assertNull(viewModel.uiState.value.passwordError) - assertFalse(viewModel.uiState.value.isLoading) + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(successCalled) + assertEquals("Password must be at least 8 characters.", state.passwordError) } @Test - fun `registerUser proceeds when inputs are valid`() = runTest { - var onSuccessCalled = false + fun `registerUser handles UsernameAlreadyExistsException`() = runTest { + fakeRepository.shouldThrowUsernameExistsException = true + viewModel.onUsernameChanged("takenUsername") viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("Password1") - viewModel.onConfirmPasswordChanged("Password1") + viewModel.onPasswordChanged("Password123") + viewModel.onConfirmPasswordChanged("Password123") - viewModel.registerUser { onSuccessCalled = true } + var successCalled = false + viewModel.registerUser { successCalled = true } - testScheduler.advanceUntilIdle() + advanceUntilIdle() - assertTrue(onSuccessCalled) - assertNull(viewModel.uiState.value.emailError) - assertNull(viewModel.uiState.value.passwordError) - assertNull(viewModel.uiState.value.confirmPasswordError) - assertFalse(viewModel.uiState.value.isLoading) + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(successCalled) + assertEquals("Username 'takenUsername' is already in use.", state.usernameError) } - // Error Handling Tests - @Test - fun `registerUser sets generalError when UserAlreadyExistsException is thrown`() = runTest { - mockRepository.shouldThrowUserAlreadyExistsException = true + fun `registerUser handles UserAlreadyExistsException`() = runTest { + fakeRepository.shouldThrowUserAlreadyExistsException = true - viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("Password1") - viewModel.onConfirmPasswordChanged("Password1") + viewModel.onUsernameChanged("uniqueUsername") + viewModel.onEmailChanged("taken@example.com") + viewModel.onPasswordChanged("Password123") + viewModel.onConfirmPasswordChanged("Password123") - viewModel.registerUser {} + var successCalled = false + viewModel.registerUser { successCalled = true } - testScheduler.advanceUntilIdle() + advanceUntilIdle() - assertEquals("An account with this email already exists.", viewModel.uiState.value.generalError) - assertFalse(viewModel.uiState.value.isLoading) + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(successCalled) + assertEquals("An account with this email already exists.", state.generalError) } @Test - fun `registerUser sets passwordError when WeakPasswordException is thrown`() = runTest { - mockRepository.shouldThrowWeakPasswordException = true + fun `registerUser handles WeakPasswordException`() = runTest { + fakeRepository.shouldThrowWeakPasswordException = true + viewModel.onUsernameChanged("uniqueUsername") viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("Password1") - viewModel.onConfirmPasswordChanged("Password1") + viewModel.onPasswordChanged("WeakPass1") + viewModel.onConfirmPasswordChanged("WeakPass1") - viewModel.registerUser {} + var successCalled = false + viewModel.registerUser { successCalled = true } - testScheduler.advanceUntilIdle() + advanceUntilIdle() - assertEquals("Your password is too weak.", viewModel.uiState.value.passwordError) - assertFalse(viewModel.uiState.value.isLoading) + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(successCalled) + assertEquals("Your password is too weak.", state.passwordError) } @Test - fun `registerUser sets generalError when unknown Exception is thrown`() = runTest { - mockRepository.shouldThrowGenericException = true + fun `registerUser handles generic Exception`() = runTest { + fakeRepository.shouldThrowGenericException = true + viewModel.onUsernameChanged("uniqueUsername") viewModel.onEmailChanged("test@example.com") - viewModel.onPasswordChanged("Password1") - viewModel.onConfirmPasswordChanged("Password1") + viewModel.onPasswordChanged("Password123") + viewModel.onConfirmPasswordChanged("Password123") - viewModel.registerUser {} + var successCalled = false + viewModel.registerUser { successCalled = true } - testScheduler.advanceUntilIdle() + advanceUntilIdle() - assertEquals("An unexpected error occurred.", viewModel.uiState.value.generalError) - assertFalse(viewModel.uiState.value.isLoading) + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(successCalled) + assertEquals("Registration failed due to an unexpected error.", state.generalError) } -} -class MockRegisterRepository : RegisterRepository { - var shouldThrowUserAlreadyExistsException = false - var shouldThrowWeakPasswordException = false - var shouldThrowGenericException = false + @Test + fun `clearFields resets state`() = runTest { + viewModel.onUsernameChanged("testUser") + viewModel.onEmailChanged("test@example.com") + viewModel.onPasswordChanged("Password123") + viewModel.onConfirmPasswordChanged("Password123") - override suspend fun registerUser(email: String, password: String) { - if (shouldThrowUserAlreadyExistsException) { - throw UserAlreadyExistsException("An account with this email already exists.") - } - if (shouldThrowWeakPasswordException) { - throw WeakPasswordException("Your password is too weak.") - } - if (shouldThrowGenericException) { - throw Exception("An unexpected error occurred.") - } + viewModel.clearFields() + val state = viewModel.uiState.value + + assertEquals("", state.username) + assertEquals("", state.email) + assertEquals("", state.password) + assertEquals("", state.confirmPassword) + assertNull(state.usernameError) + assertNull(state.emailError) + assertNull(state.passwordError) + assertNull(state.confirmPasswordError) + assertNull(state.generalError) } } diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/util/CelestialObjectsUtilTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/util/CelestialObjectsUtilTest.kt index 952dc1ea4..b36be5deb 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/util/CelestialObjectsUtilTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/util/CelestialObjectsUtilTest.kt @@ -1,4 +1,4 @@ -package com.github.lookupgroup27.lookup.utils +package com.github.lookupgroup27.lookup.util import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -55,8 +55,7 @@ class CelestialObjectsUtilsTest { * Temporary test as computeSiderealTime changes values with time emporary test as * computeSiderealTime changes values with time */ - - /*@Test + /*@Test TODO : Remove this code ??? fun `test convertToHorizonCoordinates with Stellarium data for Vega`() { // Given values derived from Stellarium val ra = 279.4380 // Right Ascension in degrees (converted from 18h 37m 45.2s) @@ -93,10 +92,10 @@ class CelestialObjectsUtilsTest { // Calculated expected values manually for testing val expectedY = - (100 * Math.cos(Math.toRadians(altitude)) * Math.cos(Math.toRadians(azimuth))).toFloat() + (Math.cos(Math.toRadians(altitude)) * Math.cos(Math.toRadians(azimuth))).toFloat() val expectedX = - (100 * Math.cos(Math.toRadians(altitude)) * Math.sin(Math.toRadians(azimuth))).toFloat() - val expectedZ = (100 * Math.sin(Math.toRadians(altitude))).toFloat() + (Math.cos(Math.toRadians(altitude)) * Math.sin(Math.toRadians(azimuth))).toFloat() + val expectedZ = (Math.sin(Math.toRadians(altitude))).toFloat() assertEquals(expectedX, x, 0.01f) assertEquals(expectedY, y, 0.01f)