diff --git a/app/src/androidTest/java/com/android/unio/components/BottomNavigationTest.kt b/app/src/androidTest/java/com/android/unio/components/BottomNavigationTest.kt index ad92eacf1..3c1a99317 100644 --- a/app/src/androidTest/java/com/android/unio/components/BottomNavigationTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/BottomNavigationTest.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import com.android.unio.TearDown +import com.android.unio.model.association.AssociationRepositoryFirestore import com.android.unio.model.event.EventRepository import com.android.unio.model.event.EventViewModel import com.android.unio.model.image.ImageRepositoryFirebaseStorage @@ -31,6 +32,7 @@ class BottomNavigationTest : TearDown() { private lateinit var eventViewModel: EventViewModel @MockK private lateinit var imageRepository: ImageRepositoryFirebaseStorage + @MockK private lateinit var associationRepositoryFirestore: AssociationRepositoryFirestore private lateinit var userRepository: UserRepository private lateinit var userViewModel: UserViewModel @@ -44,7 +46,8 @@ class BottomNavigationTest : TearDown() { fun setUp() { MockKAnnotations.init(this) eventRepository = mock { EventRepository::class.java } - eventViewModel = EventViewModel(eventRepository, imageRepository) + eventViewModel = + EventViewModel(eventRepository, imageRepository, associationRepositoryFirestore) userRepository = mock { UserRepositoryFirestore::class.java } userViewModel = UserViewModel(userRepository, false) diff --git a/app/src/androidTest/java/com/android/unio/components/ScreenDisplayingTest.kt b/app/src/androidTest/java/com/android/unio/components/ScreenDisplayingTest.kt index f7af79f54..a4756bf61 100644 --- a/app/src/androidTest/java/com/android/unio/components/ScreenDisplayingTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/ScreenDisplayingTest.kt @@ -10,6 +10,7 @@ import com.android.unio.TearDown import com.android.unio.mocks.association.MockAssociation import com.android.unio.mocks.event.MockEvent import com.android.unio.mocks.user.MockUser +import com.android.unio.model.association.AssociationRepositoryFirestore import com.android.unio.model.association.AssociationViewModel import com.android.unio.model.authentication.AuthViewModel import com.android.unio.model.event.Event @@ -106,6 +107,7 @@ class ScreenDisplayingTest : TearDown() { longitude = 6.559331096 } + @MockK private lateinit var associationRepositoryFirestore: AssociationRepositoryFirestore @MockK private lateinit var imageRepositoryFirestore: ImageRepositoryFirebaseStorage @MockK private lateinit var firebaseAuth: FirebaseAuth @@ -138,7 +140,7 @@ class ScreenDisplayingTest : TearDown() { associationViewModel = spyk( AssociationViewModel( - mock(), + associationRepositoryFirestore, mockk(), imageRepositoryFirestore, mockk())) @@ -148,7 +150,8 @@ class ScreenDisplayingTest : TearDown() { val onSuccess = args[0] as (List) -> Unit onSuccess(events) } - eventViewModel = EventViewModel(eventRepository, imageRepositoryFirestore) + eventViewModel = + EventViewModel(eventRepository, imageRepositoryFirestore, associationRepositoryFirestore) eventViewModel.loadEvents() eventViewModel.selectEvent(events.first().uid) diff --git a/app/src/androidTest/java/com/android/unio/components/association/AssociationProfileTest.kt b/app/src/androidTest/java/com/android/unio/components/association/AssociationProfileTest.kt index 59ce4beea..14958ab71 100644 --- a/app/src/androidTest/java/com/android/unio/components/association/AssociationProfileTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/association/AssociationProfileTest.kt @@ -141,7 +141,7 @@ class AssociationProfileTest : TearDown() { val onSuccess = args[0] as (List) -> Unit onSuccess(events) } - eventViewModel = EventViewModel(eventRepository, imageRepository) + eventViewModel = EventViewModel(eventRepository, imageRepository, associationRepository) every { associationRepository.init(any()) } answers { firstArg<() -> Unit>().invoke() } every { associationRepository.getAssociations(any(), any()) } answers diff --git a/app/src/androidTest/java/com/android/unio/components/event/EventCardTest.kt b/app/src/androidTest/java/com/android/unio/components/event/EventCardTest.kt index 7783145ab..2d60b30ae 100644 --- a/app/src/androidTest/java/com/android/unio/components/event/EventCardTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/event/EventCardTest.kt @@ -14,6 +14,7 @@ import com.android.unio.mocks.association.MockAssociation import com.android.unio.mocks.event.MockEvent import com.android.unio.mocks.map.MockLocation import com.android.unio.mocks.user.MockUser +import com.android.unio.model.association.AssociationRepositoryFirestore import com.android.unio.model.event.Event import com.android.unio.model.event.EventRepositoryFirestore import com.android.unio.model.event.EventType @@ -69,6 +70,7 @@ class EventCardTest : TearDown() { private lateinit var eventViewModel: EventViewModel @MockK private lateinit var eventRepository: EventRepositoryFirestore @MockK private lateinit var imageRepository: ImageRepositoryFirebaseStorage + @MockK private lateinit var associationRepository: AssociationRepositoryFirestore private lateinit var context: Context @Before @@ -79,7 +81,7 @@ class EventCardTest : TearDown() { context = InstrumentationRegistry.getInstrumentation().targetContext val user = MockUser.createMockUser(followedAssociations = associations, savedEvents = listOf()) every { NotificationWorker.schedule(any(), any()) } just runs - eventViewModel = EventViewModel(eventRepository, imageRepository) + eventViewModel = EventViewModel(eventRepository, imageRepository, associationRepository) userViewModel = UserViewModel(userRepository) every { userRepository.updateUser(user, any(), any()) } answers { @@ -93,7 +95,9 @@ class EventCardTest : TearDown() { } private fun setEventScreen(event: Event) { - composeTestRule.setContent { EventCard(navigationAction, event, userViewModel, eventViewModel) } + composeTestRule.setContent { + EventCard(navigationAction, event, userViewModel, eventViewModel, true) + } } @Test @@ -132,6 +136,10 @@ class EventCardTest : TearDown() { composeTestRule .onNodeWithTag(EventCardTestTags.EVENT_SAVE_BUTTON, useUnmergedTree = true) .assertExists() + + composeTestRule + .onNodeWithTag(EventCardTestTags.EDIT_BUTTON, useUnmergedTree = true) + .assertExists() } @Test diff --git a/app/src/androidTest/java/com/android/unio/components/event/EventCreationTest.kt b/app/src/androidTest/java/com/android/unio/components/event/EventCreationTest.kt index d79283d0b..de3ddeb21 100644 --- a/app/src/androidTest/java/com/android/unio/components/event/EventCreationTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/event/EventCreationTest.kt @@ -1,10 +1,13 @@ package com.android.unio.components.event +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.createComposeRule 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.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import com.android.unio.TearDown @@ -35,16 +38,13 @@ import com.google.firebase.auth.internal.zzac import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.MockKAnnotations -import io.mockk.clearAllMocks import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockkStatic import io.mockk.spyk -import io.mockk.unmockkAll import java.net.HttpURLConnection import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -102,7 +102,8 @@ class EventCreationTest : TearDown() { val onSuccess = args[0] as (List) -> Unit onSuccess(events) } - eventViewModel = EventViewModel(eventRepository, imageRepositoryFirestore) + eventViewModel = + EventViewModel(eventRepository, imageRepositoryFirestore, associationRepositoryFirestore) searchViewModel = spyk(SearchViewModel(searchRepository)) associationViewModel = @@ -165,6 +166,38 @@ class EventCreationTest : TearDown() { .onNodeWithTag(EventCreationTestTags.TAGGED_ASSOCIATIONS) .assertDisplayComponentInScroll() + composeTestRule + .onNodeWithTag(EventCreationTestTags.START_DATE_FIELD) + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(EventCreationTestTags.START_DATE_PICKER).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + composeTestRule + .onNodeWithTag(EventCreationTestTags.START_TIME_FIELD) + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(EventCreationTestTags.START_TIME_PICKER).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + composeTestRule + .onNodeWithTag(EventCreationTestTags.END_DATE_FIELD) + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(EventCreationTestTags.END_DATE_PICKER).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + composeTestRule + .onNodeWithTag(EventCreationTestTags.END_TIME_FIELD) + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(EventCreationTestTags.END_TIME_PICKER).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + composeTestRule.onNodeWithTag(EventCreationTestTags.TAGGED_ASSOCIATIONS).performClick() composeTestRule.waitForIdle() @@ -264,10 +297,4 @@ class EventCreationTest : TearDown() { server.shutdown() } - - @After - fun teardown() { - clearAllMocks() - unmockkAll() - } } diff --git a/app/src/androidTest/java/com/android/unio/components/event/EventEditTests.kt b/app/src/androidTest/java/com/android/unio/components/event/EventEditTests.kt new file mode 100644 index 000000000..c52b138c9 --- /dev/null +++ b/app/src/androidTest/java/com/android/unio/components/event/EventEditTests.kt @@ -0,0 +1,248 @@ +package com.android.unio.components.event + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +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.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextReplacement +import com.android.unio.TearDown +import com.android.unio.assertDisplayComponentInScroll +import com.android.unio.mocks.association.MockAssociation +import com.android.unio.mocks.event.MockEvent +import com.android.unio.mocks.user.MockUser +import com.android.unio.model.association.AssociationRepositoryFirestore +import com.android.unio.model.association.AssociationViewModel +import com.android.unio.model.event.Event +import com.android.unio.model.event.EventRepositoryFirestore +import com.android.unio.model.event.EventViewModel +import com.android.unio.model.follow.ConcurrentAssociationUserRepositoryFirestore +import com.android.unio.model.image.ImageRepositoryFirebaseStorage +import com.android.unio.model.search.SearchRepository +import com.android.unio.model.search.SearchViewModel +import com.android.unio.model.strings.test_tags.EventEditTestTags +import com.android.unio.ui.event.EventEditScreen +import com.android.unio.ui.navigation.NavigationAction +import com.google.firebase.Firebase +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.auth +import com.google.firebase.auth.internal.zzac +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.spyk +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class EventEditTests : TearDown() { + val user = MockUser.createMockUser(uid = "1") + @MockK lateinit var navigationAction: NavigationAction + @MockK private lateinit var firebaseAuth: FirebaseAuth + + // This is the implementation of the abstract method getUid() from FirebaseUser. + // Because it is impossible to mock abstract method, this is the only way to mock it. + @MockK private lateinit var mockFirebaseUser: zzac + + @get:Rule val composeTestRule = createComposeRule() + @get:Rule val hiltRule = HiltAndroidRule(this) + + val events = listOf(MockEvent.createMockEvent()) + @MockK private lateinit var eventRepository: EventRepositoryFirestore + private lateinit var eventViewModel: EventViewModel + + private lateinit var searchViewModel: SearchViewModel + @MockK(relaxed = true) private lateinit var searchRepository: SearchRepository + + private lateinit var associationViewModel: AssociationViewModel + @MockK private lateinit var associationRepositoryFirestore: AssociationRepositoryFirestore + @MockK private lateinit var eventRepositoryFirestore: EventRepositoryFirestore + @MockK private lateinit var imageRepositoryFirestore: ImageRepositoryFirebaseStorage + @MockK + private lateinit var concurrentAssociationUserRepositoryFirestore: + ConcurrentAssociationUserRepositoryFirestore + + private val mockEvent = + MockEvent.createMockEvent( + title = "Sample Event", + organisers = MockAssociation.createAllMockAssociations(), + taggedAssociations = MockAssociation.createAllMockAssociations(), + image = "https://example.com/event_image.png", + description = "This is a sample event description.", + catchyDescription = "Catchy tagline!", + price = 20.0, + startDate = MockEvent.createMockEvent().startDate, + endDate = MockEvent.createMockEvent().endDate, + location = MockEvent.createMockEvent().location, + types = listOf(MockEvent.createMockEvent().types.first())) + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + hiltRule.inject() + + mockkStatic(FirebaseAuth::class) + every { Firebase.auth } returns firebaseAuth + every { firebaseAuth.currentUser } returns mockFirebaseUser + + every { eventRepository.getEvents(any(), any()) } answers + { + val onSuccess = args[0] as (List) -> Unit + onSuccess(events) + } + eventViewModel = + spyk( + EventViewModel( + eventRepository, imageRepositoryFirestore, associationRepositoryFirestore)) + + every { eventViewModel.findEventById(any()) } returns mockEvent + eventViewModel.selectEvent(mockEvent.uid) + + searchViewModel = spyk(SearchViewModel(searchRepository)) + associationViewModel = + spyk( + AssociationViewModel( + associationRepositoryFirestore, + eventRepositoryFirestore, + imageRepositoryFirestore, + concurrentAssociationUserRepositoryFirestore)) + + val associations = MockAssociation.createAllMockAssociations(size = 2) + + every { associationViewModel.findAssociationById(any()) } returns associations.first() + associationViewModel.selectAssociation(associations.first().uid) + + every { associationViewModel.findAssociationById(any()) } returns associations.first() + every { associationViewModel.getEventsForAssociation(any(), any()) } answers + { + val onSuccess = args[1] as (List) -> Unit + onSuccess(emptyList()) + } + } + + @Test + fun testEventEditTagsDisplayed() { + composeTestRule.setContent { + EventEditScreen(navigationAction, searchViewModel, associationViewModel, eventViewModel) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(EventEditTestTags.TITLE).assertDisplayComponentInScroll() + + composeTestRule.onNodeWithTag(EventEditTestTags.EVENT_TITLE).assertDisplayComponentInScroll() + + composeTestRule + .onNodeWithTag(EventEditTestTags.SHORT_DESCRIPTION) + .assertDisplayComponentInScroll() + composeTestRule.onNodeWithTag(EventEditTestTags.COAUTHORS).assertDisplayComponentInScroll() + + composeTestRule.onNodeWithTag(EventEditTestTags.DESCRIPTION).assertDisplayComponentInScroll() + composeTestRule.onNodeWithTag(EventEditTestTags.LOCATION).assertDisplayComponentInScroll() + composeTestRule.onNodeWithTag(EventEditTestTags.DELETE_BUTTON).assertDisplayComponentInScroll() + composeTestRule.onNodeWithTag(EventEditTestTags.SAVE_BUTTON).assertDisplayComponentInScroll() + composeTestRule.onNodeWithTag(EventEditTestTags.END_TIME).assertDisplayComponentInScroll() + composeTestRule.onNodeWithTag(EventEditTestTags.START_TIME).assertDisplayComponentInScroll() + composeTestRule.onNodeWithTag(EventEditTestTags.EVENT_IMAGE).assertDisplayComponentInScroll() + + composeTestRule + .onNodeWithTag(EventEditTestTags.TAGGED_ASSOCIATIONS) + .assertDisplayComponentInScroll() + + composeTestRule + .onNodeWithTag(EventEditTestTags.START_DATE_FIELD) + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(EventEditTestTags.START_DATE_PICKER).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + composeTestRule + .onNodeWithTag(EventEditTestTags.START_TIME_FIELD) + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(EventEditTestTags.START_TIME_PICKER).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + composeTestRule + .onNodeWithTag(EventEditTestTags.END_DATE_FIELD) + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(EventEditTestTags.END_DATE_PICKER).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + composeTestRule + .onNodeWithTag(EventEditTestTags.END_TIME_FIELD) + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(EventEditTestTags.END_TIME_PICKER).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + composeTestRule.onNodeWithTag(EventEditTestTags.TAGGED_ASSOCIATIONS).performClick() + composeTestRule.waitForIdle() + } + + @Test + fun testEventCannotBeSavedWhenEmptyField() { + composeTestRule.setContent { + EventEditScreen(navigationAction, searchViewModel, associationViewModel, eventViewModel) + } + composeTestRule + .onNodeWithTag(EventEditTestTags.EVENT_TITLE, useUnmergedTree = true) + .performTextClearance() + composeTestRule.onNodeWithTag(EventEditTestTags.SAVE_BUTTON).assertIsNotEnabled() + composeTestRule.waitForIdle() + } + + @Test + fun testDeleteButtonWorksCorrectly() { + var shouldBeTrue = false + every { eventViewModel.deleteEvent(any(), any(), any()) } answers { shouldBeTrue = true } + + composeTestRule.setContent { + EventEditScreen(navigationAction, searchViewModel, associationViewModel, eventViewModel) + } + + composeTestRule.onNodeWithTag(EventEditTestTags.DELETE_BUTTON).performScrollTo().performClick() + composeTestRule.waitForIdle() + assert(shouldBeTrue) + } + + @Test + fun testSaveButtonSavesNewEvent() { + var shouldBeTrue = false + + val eventSlot = slot() + every { eventViewModel.updateEventWithoutImage(capture(eventSlot), any(), any()) } answers + { + shouldBeTrue = true + } + + composeTestRule.setContent { + EventEditScreen(navigationAction, searchViewModel, associationViewModel, eventViewModel) + } + composeTestRule + .onNodeWithTag(EventEditTestTags.EVENT_TITLE) + .performTextReplacement("New Sample Event") + + composeTestRule.onNodeWithTag(EventEditTestTags.SAVE_BUTTON).performScrollTo().performClick() + + composeTestRule.waitForIdle() + + val result = eventSlot.captured + assert(shouldBeTrue) + assert(result.title != mockEvent.title) + assert(result.description == mockEvent.description) + } +} diff --git a/app/src/androidTest/java/com/android/unio/components/home/HomeTest.kt b/app/src/androidTest/java/com/android/unio/components/home/HomeTest.kt index c7e1796bd..878b0a662 100644 --- a/app/src/androidTest/java/com/android/unio/components/home/HomeTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/home/HomeTest.kt @@ -15,6 +15,7 @@ import com.android.unio.assertDisplayComponentInScroll import com.android.unio.mocks.association.MockAssociation import com.android.unio.mocks.event.MockEvent import com.android.unio.mocks.user.MockUser +import com.android.unio.model.association.AssociationRepositoryFirestore import com.android.unio.model.event.Event import com.android.unio.model.event.EventRepositoryFirestore import com.android.unio.model.event.EventViewModel @@ -69,6 +70,7 @@ class HomeTest : TearDown() { @MockK private lateinit var userRepository: UserRepositoryFirestore @MockK private lateinit var imageRepository: ImageRepositoryFirebaseStorage @MockK private lateinit var navigationAction: NavigationAction + @MockK private lateinit var associationRepositoryFirestore: AssociationRepositoryFirestore private lateinit var eventViewModel: EventViewModel private lateinit var searchViewModel: SearchViewModel @@ -120,7 +122,8 @@ class HomeTest : TearDown() { val onSuccess = args[0] as (List) -> Unit onSuccess(eventList) } - eventViewModel = EventViewModel(eventRepository, imageRepository) + eventViewModel = + EventViewModel(eventRepository, imageRepository, associationRepositoryFirestore) eventListFollowed = asso.let { eventList.filter { event -> event.organisers.contains(it.uid) } } } @@ -140,7 +143,8 @@ class HomeTest : TearDown() { composeTestRule.setContent { val context = LocalContext.current text = context.getString(R.string.event_no_events_available) - val eventViewModel = EventViewModel(eventRepository, imageRepository) + val eventViewModel = + EventViewModel(eventRepository, imageRepository, associationRepositoryFirestore) HomeScreen(navigationAction, eventViewModel, userViewModel, searchViewModel) } composeTestRule.onNodeWithTag(HomeTestTags.EMPTY_EVENT_PROMPT).assertExists() @@ -188,7 +192,8 @@ class HomeTest : TearDown() { @Test fun testMapButton() { composeTestRule.setContent { - val eventViewModel = EventViewModel(eventRepository, imageRepository) + val eventViewModel = + EventViewModel(eventRepository, imageRepository, associationRepositoryFirestore) HomeScreen(navigationAction, eventViewModel, userViewModel, searchViewModel) } composeTestRule.onNodeWithTag(HomeTestTags.MAP_BUTTON).assertExists() @@ -206,7 +211,8 @@ class HomeTest : TearDown() { @Test fun testClickFollowingAndAdd() = runBlockingTest { composeTestRule.setContent { - val eventViewModel = EventViewModel(eventRepository, imageRepository) + val eventViewModel = + EventViewModel(eventRepository, imageRepository, associationRepositoryFirestore) HomeScreen(navigationAction, eventViewModel, userViewModel, searchViewModel) } diff --git a/app/src/androidTest/java/com/android/unio/components/map/MapScreenTest.kt b/app/src/androidTest/java/com/android/unio/components/map/MapScreenTest.kt index a30011233..8e64ff496 100644 --- a/app/src/androidTest/java/com/android/unio/components/map/MapScreenTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/map/MapScreenTest.kt @@ -12,6 +12,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import com.android.unio.TearDown import com.android.unio.mocks.user.MockUser +import com.android.unio.model.association.AssociationRepositoryFirestore import com.android.unio.model.event.EventRepositoryFirestore import com.android.unio.model.event.EventViewModel import com.android.unio.model.image.ImageRepositoryFirebaseStorage @@ -54,6 +55,7 @@ class MapScreenTest : TearDown() { @MockK private lateinit var imageRepository: ImageRepositoryFirebaseStorage @MockK private lateinit var userRepository: UserRepositoryFirestore @MockK private lateinit var navHostController: NavHostController + @MockK private lateinit var associationRepositoryFirestore: AssociationRepositoryFirestore private lateinit var locationTask: Task private lateinit var context: Context @@ -76,7 +78,8 @@ class MapScreenTest : TearDown() { navigationAction = NavigationAction(navHostController) every { eventRepository.init(any()) } answers {} - eventViewModel = EventViewModel(eventRepository, imageRepository) + eventViewModel = + EventViewModel(eventRepository, imageRepository, associationRepositoryFirestore) every { userRepository.init(any()) } returns Unit every { userRepository.getUserWithId("123", any(), any()) } answers diff --git a/app/src/androidTest/java/com/android/unio/components/notification/NotificationTest.kt b/app/src/androidTest/java/com/android/unio/components/notification/NotificationTest.kt index ef57ecf10..11a7b11e3 100644 --- a/app/src/androidTest/java/com/android/unio/components/notification/NotificationTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/notification/NotificationTest.kt @@ -7,6 +7,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.android.unio.R import com.android.unio.TearDown +import com.android.unio.model.association.AssociationRepositoryFirestore import com.android.unio.model.event.EventRepositoryFirestore import com.android.unio.model.event.EventViewModel import com.android.unio.model.image.ImageRepositoryFirebaseStorage @@ -46,7 +47,7 @@ class NotificationTest : TearDown() { @MockK(relaxed = true) private lateinit var searchRepository: SearchRepository @MockK private lateinit var imageRepository: ImageRepositoryFirebaseStorage - + @MockK private lateinit var associationRepository: AssociationRepositoryFirestore @MockK private lateinit var userRepository: UserRepositoryFirestore private lateinit var eventViewModel: EventViewModel private lateinit var searchViewModel: SearchViewModel @@ -69,7 +70,7 @@ class NotificationTest : TearDown() { every { userRepository.init(any()) } just runs context = InstrumentationRegistry.getInstrumentation().targetContext searchViewModel = spyk(SearchViewModel(searchRepository)) - eventViewModel = EventViewModel(eventRepository, imageRepository) + eventViewModel = EventViewModel(eventRepository, imageRepository, associationRepository) userViewModel = spyk(UserViewModel(userRepository)) } diff --git a/app/src/androidTest/java/com/android/unio/components/saved/SavedTest.kt b/app/src/androidTest/java/com/android/unio/components/saved/SavedTest.kt index bf934e46d..1821e740b 100644 --- a/app/src/androidTest/java/com/android/unio/components/saved/SavedTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/saved/SavedTest.kt @@ -7,6 +7,7 @@ import com.android.unio.assertDisplayComponentInScroll import com.android.unio.mocks.association.MockAssociation import com.android.unio.mocks.event.MockEvent import com.android.unio.mocks.user.MockUser +import com.android.unio.model.association.AssociationRepositoryFirestore import com.android.unio.model.event.Event import com.android.unio.model.event.EventRepositoryFirestore import com.android.unio.model.event.EventViewModel @@ -38,11 +39,9 @@ class SavedTest : TearDown() { // Mock event repository to provide test data. @MockK private lateinit var eventRepository: EventRepositoryFirestore - @MockK private lateinit var userRepository: UserRepositoryFirestore - @MockK private lateinit var navigationAction: NavigationAction - + @MockK private lateinit var associationRepositoryFirestore: AssociationRepositoryFirestore @MockK private lateinit var imageRepository: ImageRepositoryFirebaseStorage private lateinit var eventViewModel: EventViewModel @@ -87,7 +86,8 @@ class SavedTest : TearDown() { onSuccess() } - eventViewModel = EventViewModel(eventRepository, imageRepository) + eventViewModel = + EventViewModel(eventRepository, imageRepository, associationRepositoryFirestore) } @Test diff --git a/app/src/main/java/com/android/unio/MainActivity.kt b/app/src/main/java/com/android/unio/MainActivity.kt index 01e83d289..54eabe33a 100644 --- a/app/src/main/java/com/android/unio/MainActivity.kt +++ b/app/src/main/java/com/android/unio/MainActivity.kt @@ -38,6 +38,7 @@ import com.android.unio.ui.authentication.EmailVerificationScreen import com.android.unio.ui.authentication.ResetPasswordScreen import com.android.unio.ui.authentication.WelcomeScreen import com.android.unio.ui.event.EventCreationScreen +import com.android.unio.ui.event.EventEditScreen import com.android.unio.ui.event.EventScreen import com.android.unio.ui.explore.ExploreScreen import com.android.unio.ui.home.HomeScreen @@ -163,6 +164,9 @@ fun UnioApp(imageRepository: ImageRepositoryFirebaseStorage) { nominatimLocationSearchViewModel) } } + composable(Screen.EDIT_EVENT) { + EventEditScreen(navigationActions, searchViewModel, associationViewModel, eventViewModel) + } } navigation(startDestination = Screen.SAVED, route = Route.SAVED) { composable(Screen.SAVED) { SavedScreen(navigationActions, eventViewModel, userViewModel) } diff --git a/app/src/main/java/com/android/unio/model/event/EventRepositoryFirestore.kt b/app/src/main/java/com/android/unio/model/event/EventRepositoryFirestore.kt index 469f13d87..e8d9d560a 100644 --- a/app/src/main/java/com/android/unio/model/event/EventRepositoryFirestore.kt +++ b/app/src/main/java/com/android/unio/model/event/EventRepositoryFirestore.kt @@ -84,6 +84,7 @@ class EventRepositoryFirestore @Inject constructor(private val db: FirebaseFires return db.collection(EVENT_PATH).document().id } + /** Updates the event in the repository or adds it if it does not exist. */ override fun addEvent(event: Event, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) { if (event.uid.isBlank()) { onFailure(IllegalArgumentException("No event id was provided")) diff --git a/app/src/main/java/com/android/unio/model/event/EventViewModel.kt b/app/src/main/java/com/android/unio/model/event/EventViewModel.kt index f9f4900cb..e0400ffdd 100644 --- a/app/src/main/java/com/android/unio/model/event/EventViewModel.kt +++ b/app/src/main/java/com/android/unio/model/event/EventViewModel.kt @@ -2,6 +2,7 @@ package com.android.unio.model.event import android.util.Log import androidx.lifecycle.ViewModel +import com.android.unio.model.association.AssociationRepository import com.android.unio.model.image.ImageRepository import dagger.hilt.android.lifecycle.HiltViewModel import java.io.InputStream @@ -20,8 +21,11 @@ import kotlinx.coroutines.flow.asStateFlow @HiltViewModel class EventViewModel @Inject -constructor(private val repository: EventRepository, private val imageRepository: ImageRepository) : - ViewModel() { +constructor( + private val repository: EventRepository, + private val imageRepository: ImageRepository, + private val associationRepository: AssociationRepository +) : ViewModel() { /** * A private mutable state flow that holds the list of events. It is internal to the ViewModel and @@ -94,7 +98,110 @@ constructor(private val repository: EventRepository, private val imageRepository repository.addEvent(event, onSuccess, onFailure) }, { e -> Log.e("ImageRepository", "Failed to store image: $e") }) - event.organisers.requestAll(onSuccess) + + event.organisers.requestAll({ + event.organisers.list.value.forEach { + it.events.add(event.uid) + associationRepository.saveAssociation( + it, + { it.events.requestAll() }, + { e -> Log.e("EventViewModel", "An error occurred while loading associations: $e") }) + } + }) _events.value += event } + + /** + * Update an existing event in the repository with a new image. It uploads the event image first, + * then updates the event. + * + * @param inputStream The input stream of the image to upload. + * @param event The event to update. + * @param onSuccess A callback that is called when the event is successfully updated. + * @param onFailure A callback that is called when an error occurs while updating the event. + */ + fun updateEvent( + inputStream: InputStream, + event: Event, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + imageRepository.uploadImage( + inputStream, + "images/events/${event.uid}", + { uri -> + event.image = uri + repository.addEvent(event, onSuccess, onFailure) + }, + { e -> Log.e("ImageRepository", "Failed to store image: $e") }) + + event.organisers.requestAll({ + event.organisers.list.value.forEach { + it.events.add(event.uid) + associationRepository.saveAssociation( + it, + {}, + { e -> Log.e("EventViewModel", "An error occurred while loading associations: $e") }) + it.events.requestAll() + } + }) + + _events.value = _events.value.filter { it.uid != event.uid } // Remove the outdated event + _events.value += event + } + + /** + * Update an existing event in the repository without updating its image. + * + * @param event The event to update. + * @param onSuccess A callback that is called when the event is successfully updated. + * @param onFailure A callback that is called when an error occurs while updating the event. + */ + fun updateEventWithoutImage(event: Event, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) { + repository.addEvent(event, onSuccess, onFailure) + + event.organisers.requestAll({ + event.organisers.list.value.forEach { + it.events.add(event.uid) + associationRepository.saveAssociation( + it, + {}, + { e -> Log.e("EventViewModel", "An error occurred while loading associations: $e") }) + it.events.requestAll() + } + }) + + _events.value = _events.value.filter { it.uid != event.uid } // Remove the outdated event + _events.value += event + } + + /** + * Deletes an event from the repository. + * + * @param event The event to delete. + * @param onSuccess A callback that is called when the event is successfully deleted. + * @param onFailure A callback that is called when an error occurs while deleting the event. + */ + fun deleteEvent(event: Event, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) { + repository.deleteEventById( + event.uid, + onSuccess = { + _events.value = _events.value.filter { it.uid != event.uid } + onSuccess() + }, + onFailure = { exception -> + Log.e("EventViewModel", "An error occurred while deleting event: $exception") + }) + + event.organisers.requestAll({ + event.organisers.list.value.forEach { + it.events.remove(event.uid) + associationRepository.saveAssociation( + it, + {}, + { e -> Log.e("EventViewModel", "An error occurred while loading associations: $e") }) + it.events.requestAll() + } + }) + } } diff --git a/app/src/main/java/com/android/unio/model/strings/test_tags/EventTestTags.kt b/app/src/main/java/com/android/unio/model/strings/test_tags/EventTestTags.kt index 2d5b0995e..2eba0af2e 100644 --- a/app/src/main/java/com/android/unio/model/strings/test_tags/EventTestTags.kt +++ b/app/src/main/java/com/android/unio/model/strings/test_tags/EventTestTags.kt @@ -10,6 +10,7 @@ object EventCardTestTags { const val EVENT_DATE = "event_EventDate" const val EVENT_TIME = "event_EventTime" const val EVENT_CATCHY_DESCRIPTION = "event_EventCatchyDescription" + const val EDIT_BUTTON = "event_EditButton" const val EVENT_SAVE_BUTTON = "event_EventSaveButton" } @@ -29,6 +30,14 @@ object EventCreationTestTags { const val ERROR_TEXT1 = "eventCreationStartAfterEnd" const val ERROR_TEXT2 = "eventCreationStartEqualsEnd" const val LOCATION_SUGGESTION_ITEM = "eventCreationSuggestionItem: " + const val START_DATE_FIELD = "eventCreationOverlayStartDateField" + const val START_TIME_FIELD = "eventCreationOverlayStartTimeField" + const val START_DATE_PICKER = "eventCreationOverlayStartDatePicker" + const val START_TIME_PICKER = "eventCreationOverlayStartTimePicker" + const val END_DATE_FIELD = "eventCreationOverlayEndDateField" + const val END_TIME_FIELD = "eventCreationOverlayEndTimeField" + const val END_DATE_PICKER = "eventCreationOverlayEndDatePicker" + const val END_TIME_PICKER = "eventCreationOverlayEndTimePicker" } object EventCreationOverlayTestTags { @@ -41,6 +50,32 @@ object EventCreationOverlayTestTags { const val ASSOCIATION_LIST = "eventCreationOverlayAssociationList" } +object EventEditTestTags { + const val SCREEN = "eventEditScreen" + const val TITLE = "eventEditTitle" + const val EVENT_TITLE = "eventEditEventTitle" + const val SHORT_DESCRIPTION = "eventEditShortDescription" + const val COAUTHORS = "eventEditCoauthors" + const val TAGGED_ASSOCIATIONS = "eventEditTaggedAssociations" + const val DESCRIPTION = "eventEditDescription" + const val LOCATION = "eventEditLocation" + const val SAVE_BUTTON = "eventEditSaveButton" + const val DELETE_BUTTON = "eventEditDeleteButton" + const val EVENT_IMAGE = "eventEditEventImage" + const val START_TIME = "eventEditStartTime" + const val END_TIME = "eventEditEndTime" + const val ERROR_TEXT1 = "eventEditStartAfterEnd" + const val ERROR_TEXT2 = "eventEditStartEqualsEnd" + const val START_DATE_FIELD = "eventEditOverlayStartDateField" + const val START_TIME_FIELD = "eventEditOverlayStartTimeField" + const val START_DATE_PICKER = "eventEditOverlayStartDatePicker" + const val START_TIME_PICKER = "eventEditOverlayStartTimePicker" + const val END_DATE_FIELD = "eventEditOverlayEndDateField" + const val END_TIME_FIELD = "eventEditOverlayEndTimeField" + const val END_DATE_PICKER = "eventEditOverlayEndDatePicker" + const val END_TIME_PICKER = "eventEditOverlayEndTimePicker" +} + object EventDetailsTestTags { const val SCREEN = "eventScreen" const val SNACKBAR_HOST = "snackbarHost" diff --git a/app/src/main/java/com/android/unio/ui/association/AssociationProfile.kt b/app/src/main/java/com/android/unio/ui/association/AssociationProfile.kt index 307a85d1f..ab61cdd2e 100644 --- a/app/src/main/java/com/android/unio/ui/association/AssociationProfile.kt +++ b/app/src/main/java/com/android/unio/ui/association/AssociationProfile.kt @@ -138,7 +138,6 @@ fun AssociationProfileScaffold( associationViewModel: AssociationViewModel, onEdit: () -> Unit ) { - val associationState by associationViewModel.selectedAssociation.collectAsState() val association = associationState!! @@ -437,6 +436,7 @@ private fun AssociationMembers(members: List, onMemberClick: (User) -> Uni * @param navigationAction (NavigationAction) : The navigation actions of the screen * @param association (Association) : The association currently displayed * @param userViewModel (UserViewModel) : The user view model + * @param eventViewModel (EventViewModel) : The event view model */ @Composable private fun AssociationEvents( @@ -451,6 +451,7 @@ private fun AssociationEvents( val events by association.events.list.collectAsState() + // To be changed when we have a functional admin system var isAdmin by remember { mutableStateOf(true) } if (events.isNotEmpty()) { @@ -513,7 +514,8 @@ private fun AssociationEventCard( navigationAction = navigationAction, event = event, userViewModel = userViewModel, - eventViewModel = eventViewModel) + eventViewModel = eventViewModel, + true) } } diff --git a/app/src/main/java/com/android/unio/ui/components/EventEditComponents.kt b/app/src/main/java/com/android/unio/ui/components/EventEditComponents.kt new file mode 100644 index 000000000..c8c0d1485 --- /dev/null +++ b/app/src/main/java/com/android/unio/ui/components/EventEditComponents.kt @@ -0,0 +1,325 @@ +package com.android.unio.ui.components + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimeInput +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import com.android.unio.R +import com.android.unio.model.association.Association +import com.android.unio.model.strings.FormatStrings.DAY_MONTH_YEAR_FORMAT +import com.android.unio.model.strings.FormatStrings.HOUR_MINUTE_FORMAT +import com.google.firebase.Timestamp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AssociationChips( + associations: List>>, +) { + val context = LocalContext.current + FlowRow { + associations.forEach { (association, selected) -> + if (selected.value) { + InputChip( + label = { Text(association.name) }, + onClick = {}, + selected = selected.value, + avatar = { + Icon( + Icons.Default.Close, + contentDescription = context.getString(R.string.associations_overlay_remove), + modifier = Modifier.clickable { selected.value = !selected.value }) + }) + } + } + } +} + +@Composable +fun BannerImagePicker(eventBannerUri: MutableState, modifier: Modifier) { + val context = LocalContext.current + + val pickMedia = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri: Uri? -> uri?.let { eventBannerUri.value = it } }) + + Box( + modifier.size(390.dp, 100.dp).clip(RoundedCornerShape(4.dp)).clickable { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + contentAlignment = Alignment.Center) { + if (eventBannerUri.value != Uri.EMPTY) { + Image( + painter = rememberAsyncImagePainter(eventBannerUri.value), + contentDescription = + context.getString(R.string.event_creation_selected_image_description), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop) + } else { + Image( + painter = painterResource(id = R.drawable.adec), + contentDescription = + context.getString(R.string.event_creation_placeholder_image_description), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop) + Text( + text = context.getString(R.string.event_creation_image_label), + modifier = Modifier.align(Alignment.Center)) + } + } +} + +@Composable +fun DateAndTimePicker( + dateString: String, + timeString: String, + modifier: Modifier, + initialDate: Long?, + initialTime: Long?, + dateFieldTestTag: String, + timeFieldTestTag: String, + datePickerTestTag: String, + timePickerTestTag: String, + onTimestamp: (Timestamp) -> Unit +) { + var isDatePickerVisible by remember { mutableStateOf(false) } + var isTimePickerVisible by remember { mutableStateOf(false) } + var selectedDate by remember { mutableStateOf(null) } + var selectedTime by remember { mutableStateOf(null) } + val context = LocalContext.current + + Row( + modifier.fillMaxWidth(), + ) { + OutlinedTextField( + modifier = + Modifier.testTag(dateFieldTestTag).weight(1f).pointerInput(Unit) { + awaitEachGesture { + // Modifier.clickable doesn't work for text fields, so we use Modifier.pointerInput + // in the Initial pass to observe events before the text field consumes them + // in the Main pass. + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) { + isDatePickerVisible = true + } + } + }, + value = + selectedDate?.let { convertMillisToDate(it) } + ?: initialDate?.let { convertMillisToDate(it) } + ?: "", + readOnly = true, + onValueChange = {}, + trailingIcon = { + Icon( + Icons.Default.DateRange, + contentDescription = context.getString(R.string.event_edit_date_picker_desc)) + }, + placeholder = { Text(context.getString(R.string.event_creation_placeholder_date_input)) }, + label = { Text(dateString) }) + Spacer(modifier = Modifier.weight(0.05f)) + OutlinedTextField( + modifier = + Modifier.testTag(timeFieldTestTag).weight(1f).pointerInput(Unit) { + awaitEachGesture { + // Modifier.clickable doesn't work for text fields, so we use Modifier.pointerInput + // in the Initial pass to observe events before the text field consumes them + // in the Main pass. + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) { + isTimePickerVisible = true + } + } + }, + value = + selectedTime?.let { convertMillisToTime(it) } + ?: initialTime?.let { convertMillisToTime(it) } + ?: "", + readOnly = true, + onValueChange = {}, + trailingIcon = { + Icon( + Icons.Default.AccessTime, + contentDescription = context.getString(R.string.event_edit_time_picker_desc)) + }, + placeholder = { Text(context.getString(R.string.event_creation_placeholder_time_input)) }, + label = { Text(timeString) }) + } + + if (isDatePickerVisible) { + DatePickerModal( + modifier = Modifier.testTag(datePickerTestTag), + onDateSelected = { + selectedDate = it + isDatePickerVisible = false + }, + onDismiss = { isDatePickerVisible = false }) + } + + if (isTimePickerVisible) { + TimePickerModal( + modifier = Modifier.testTag(timePickerTestTag), + onTimeSelected = { + selectedTime = it + isTimePickerVisible = false + }, + onDismiss = { isTimePickerVisible = false }) + } + + // Allows to only partially fill the date and time fields + if (selectedDate != null && selectedTime != null) { + onTimestamp(Timestamp(Date(selectedDate!! + selectedTime!!))) + } else if (selectedDate != null && initialTime != null) { + onTimestamp(Timestamp(Date(selectedDate!! + initialTime))) + } else if (initialDate != null && selectedTime != null) { + onTimestamp(Timestamp(Date(initialDate + selectedTime!!))) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DatePickerModal( + onDateSelected: (Long?) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val datePickerState = rememberDatePickerState() + val context = LocalContext.current + + DatePickerDialog( + modifier = modifier, + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onDateSelected(datePickerState.selectedDateMillis) + onDismiss() + }) { + Text(context.getString(R.string.event_creation_dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(context.getString(R.string.event_creation_dialog_cancel)) + } + }) { + DatePicker(state = datePickerState) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimePickerModal(onTimeSelected: (Long?) -> Unit, onDismiss: () -> Unit, modifier: Modifier) { + val timePickerState = rememberTimePickerState(is24Hour = true) + + TimePickerDialog( + modifier = modifier, + onDismiss = onDismiss, + onConfirm = { + onTimeSelected( + timePickerState.hour * 60 * 60 * 1000L - 3600000 + timePickerState.minute * 60 * 1000L) + onDismiss() + }) { + TimeInput(state = timePickerState) + } +} + +/** + * A Dialog that is the analog of the DatePickerDialog, but for TimePicker as it currently does not + * exist in the Material3 library. + */ +@Composable +fun TimePickerDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier, + content: @Composable () -> Unit +) { + val context = LocalContext.current + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + dismissButton = { + TextButton(onClick = { onDismiss() }) { + Text(context.getString(R.string.event_creation_dialog_cancel)) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm() }) { + Text(context.getString(R.string.event_creation_dialog_ok)) + } + }, + text = { content() }) +} + +fun convertMillisToDate(millis: Long): String { + val formatter = SimpleDateFormat(DAY_MONTH_YEAR_FORMAT, Locale.getDefault()) + return formatter.format(Date(millis)) +} + +fun convertMillisToTime(millis: Long): String { + val formatter = SimpleDateFormat(HOUR_MINUTE_FORMAT, Locale.getDefault()) + return formatter.format(Date(millis)) +} + +/** + * Returns the time of the hours and minutes component in milliseconds from the timestamp in seconds + * + * @param timestamp: Timestamp + * @return Long how many milliseconds have passed since the beginning of the day + */ +fun getHHMMInMillisFromTimestamp(timestamp: Timestamp): Long { + return ((timestamp.toDate().hours - 1) * 60 + timestamp.toDate().minutes) * 60 * 1000L +} diff --git a/app/src/main/java/com/android/unio/ui/event/EventCard.kt b/app/src/main/java/com/android/unio/ui/event/EventCard.kt index 3a74f3813..aa133e71b 100644 --- a/app/src/main/java/com/android/unio/ui/event/EventCard.kt +++ b/app/src/main/java/com/android/unio/ui/event/EventCard.kt @@ -9,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -21,9 +22,11 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -74,7 +77,9 @@ fun EventCard( navigationAction: NavigationAction, event: Event, userViewModel: UserViewModel, - eventViewModel: EventViewModel + eventViewModel: EventViewModel, + shouldBeEditable: Boolean = + false // To be changed in the future once permissions are implemented ) { val context = LocalContext.current val user by userViewModel.user.collectAsState() @@ -154,7 +159,12 @@ fun EventCard( eventViewModel.selectEvent(event.uid) navigationAction.navigateTo(Screen.EVENT_DETAILS) }, - onClickSaveButton = onClickSaveButton) + onClickSaveButton = onClickSaveButton, + onClickEditButton = { + eventViewModel.selectEvent(event.uid) + navigationAction.navigateTo(Screen.EDIT_EVENT) + }, + shouldBeEditable = shouldBeEditable) } @Composable @@ -163,7 +173,9 @@ fun EventCardScaffold( organisers: List, isSaved: Boolean, onClickEventCard: () -> Unit, - onClickSaveButton: () -> Unit + onClickSaveButton: () -> Unit, + onClickEditButton: () -> Unit, + shouldBeEditable: Boolean ) { val context = LocalContext.current Column( @@ -192,26 +204,46 @@ fun EventCardScaffold( // Save button icon on the top right corner of the image, allows the user to save/unsave // the event - - Box( - modifier = - Modifier.size(28.dp) - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.inversePrimary) - .align(Alignment.TopEnd) - .clickable { onClickSaveButton() } - .padding(4.dp) - .testTag(EventCardTestTags.EVENT_SAVE_BUTTON)) { - Icon( - imageVector = - if (isSaved) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, - contentDescription = - if (isSaved) - context.getString(R.string.event_card_content_description_saved_event) - else - context.getString( - R.string.event_card_content_description_not_saved_event), - tint = if (isSaved) Color.Red else Color.White) + Row( + modifier = Modifier.align(Alignment.TopEnd).padding(2.dp), + horizontalArrangement = Arrangement.SpaceBetween) { + if (shouldBeEditable) { + IconButton( + modifier = + Modifier.size(28.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.inversePrimary) + .padding(4.dp) + .testTag(EventCardTestTags.EDIT_BUTTON), + onClick = { onClickEditButton() }) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = "editassociation", + tint = Color.White) + } + } + Spacer(modifier = Modifier.width(2.dp)) + + Box( + modifier = + Modifier.size(28.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.inversePrimary) + .clickable { onClickSaveButton() } + .padding(4.dp) + .testTag(EventCardTestTags.EVENT_SAVE_BUTTON)) { + Icon( + imageVector = + if (isSaved) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + contentDescription = + if (isSaved) + context.getString( + R.string.event_card_content_description_saved_event) + else + context.getString( + R.string.event_card_content_description_not_saved_event), + tint = if (isSaved) Color.Red else Color.White) + } } } diff --git a/app/src/main/java/com/android/unio/ui/event/EventCreation.kt b/app/src/main/java/com/android/unio/ui/event/EventCreation.kt index 5061f44e2..55c133db4 100644 --- a/app/src/main/java/com/android/unio/ui/event/EventCreation.kt +++ b/app/src/main/java/com/android/unio/ui/event/EventCreation.kt @@ -2,58 +2,32 @@ package com.android.unio.ui.event import android.net.Uri import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Divider import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TimeInput -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,16 +36,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties -import coil.compose.rememberAsyncImagePainter import com.android.unio.R import com.android.unio.model.association.Association import com.android.unio.model.association.AssociationViewModel @@ -81,16 +49,14 @@ import com.android.unio.model.firestore.firestoreReferenceListWith import com.android.unio.model.map.Location import com.android.unio.model.map.nominatim.NominatimLocationSearchViewModel import com.android.unio.model.search.SearchViewModel -import com.android.unio.model.strings.FormatStrings.DAY_MONTH_YEAR_FORMAT -import com.android.unio.model.strings.FormatStrings.HOUR_MINUTE_FORMAT import com.android.unio.model.strings.test_tags.EventCreationTestTags +import com.android.unio.ui.components.AssociationChips +import com.android.unio.ui.components.BannerImagePicker +import com.android.unio.ui.components.DateAndTimePicker import com.android.unio.ui.event.overlay.AssociationsOverlay import com.android.unio.ui.navigation.NavigationAction import com.android.unio.ui.theme.AppTypography import com.google.firebase.Timestamp -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale private const val DROP_DOWN_MAX_CHARACTERS = 40 private const val DROP_DOWN_MAX_ROWS = 3 @@ -161,7 +127,8 @@ fun EventCreationScreen( onValueChange = { shortDescription = it }, label = { Text(context.getString(R.string.event_creation_short_description_label)) }) - BannerImagePicker(eventBannerUri) + BannerImagePicker( + eventBannerUri, modifier = Modifier.testTag(EventCreationTestTags.EVENT_IMAGE)) OutlinedButton( modifier = Modifier.fillMaxWidth().testTag(EventCreationTestTags.COAUTHORS), @@ -196,14 +163,26 @@ fun EventCreationScreen( DateAndTimePicker( context.getString(R.string.event_creation_startdate_label), context.getString(R.string.event_creation_starttime_label), - modifier = Modifier.testTag(EventCreationTestTags.START_TIME)) { + modifier = Modifier.testTag(EventCreationTestTags.START_TIME), + null, + null, + EventCreationTestTags.START_DATE_FIELD, + EventCreationTestTags.START_TIME_FIELD, + EventCreationTestTags.START_DATE_PICKER, + EventCreationTestTags.START_TIME_PICKER) { startTimestamp = it } DateAndTimePicker( context.getString(R.string.event_creation_enddate_label), context.getString(R.string.event_creation_endtime_label), - modifier = Modifier.testTag(EventCreationTestTags.END_TIME)) { + modifier = Modifier.testTag(EventCreationTestTags.END_TIME), + null, + null, + EventCreationTestTags.END_DATE_FIELD, + EventCreationTestTags.END_TIME_FIELD, + EventCreationTestTags.END_DATE_PICKER, + EventCreationTestTags.END_TIME_PICKER) { endTimestamp = it } if (startTimestamp != null && endTimestamp != null) { @@ -353,227 +332,3 @@ fun EventCreationScreen( } } } - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun AssociationChips( - associations: List>>, -) { - val context = LocalContext.current - FlowRow { - associations.forEach { (association, selected) -> - if (selected.value) { - InputChip( - label = { Text(association.name) }, - onClick = {}, - selected = selected.value, - avatar = { - Icon( - Icons.Default.Close, - contentDescription = context.getString(R.string.associations_overlay_remove), - modifier = Modifier.clickable { selected.value = !selected.value }) - }) - } - } - } -} - -@Composable -private fun BannerImagePicker(eventBannerUri: MutableState) { - val context = LocalContext.current - - val pickMedia = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickVisualMedia(), - onResult = { uri: Uri? -> uri?.let { eventBannerUri.value = it } }) - - Box( - modifier = - Modifier.size(390.dp, 100.dp) - .clip(RoundedCornerShape(4.dp)) - .testTag(EventCreationTestTags.EVENT_IMAGE) - .clickable { - pickMedia.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - }, - contentAlignment = Alignment.Center) { - if (eventBannerUri.value != Uri.EMPTY) { - Image( - painter = rememberAsyncImagePainter(eventBannerUri.value), - contentDescription = - context.getString(R.string.event_creation_selected_image_description), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop) - } else { - Image( - painter = painterResource(id = R.drawable.adec), - contentDescription = - context.getString(R.string.event_creation_placeholder_image_description), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop) - Text( - text = context.getString(R.string.event_creation_image_label), - modifier = Modifier.align(Alignment.Center)) - } - } -} - -@Composable -private fun DateAndTimePicker( - dateString: String, - timeString: String, - modifier: Modifier, - onTimestamp: (Timestamp) -> Unit -) { - var isDatePickerVisible by remember { mutableStateOf(false) } - var isTimePickerVisible by remember { mutableStateOf(false) } - var selectedDate by remember { mutableStateOf(null) } - var selectedTime by remember { mutableStateOf(null) } - val context = LocalContext.current - - Row( - modifier.fillMaxWidth(), - ) { - OutlinedTextField( - modifier = - Modifier.weight(1f).pointerInput(Unit) { - awaitEachGesture { - // Modifier.clickable doesn't work for text fields, so we use Modifier.pointerInput - // in the Initial pass to observe events before the text field consumes them - // in the Main pass. - awaitFirstDown(pass = PointerEventPass.Initial) - val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) - if (upEvent != null) { - isDatePickerVisible = true - } - } - }, - value = selectedDate?.let { convertMillisToDate(it) } ?: "", - readOnly = true, - onValueChange = {}, - trailingIcon = { Icon(Icons.Default.DateRange, contentDescription = "Select date") }, - placeholder = { Text(context.getString(R.string.event_creation_placeholder_date_input)) }, - label = { Text(dateString) }) - Spacer(modifier = Modifier.weight(0.05f)) - OutlinedTextField( - modifier = - Modifier.weight(1f).pointerInput(Unit) { - awaitEachGesture { - // Modifier.clickable doesn't work for text fields, so we use Modifier.pointerInput - // in the Initial pass to observe events before the text field consumes them - // in the Main pass. - awaitFirstDown(pass = PointerEventPass.Initial) - val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) - if (upEvent != null) { - isTimePickerVisible = true - } - } - }, - value = selectedTime?.let { convertMillisToTime(it) } ?: "", - readOnly = true, - onValueChange = {}, - trailingIcon = { Icon(Icons.Default.AccessTime, contentDescription = "Select date") }, - placeholder = { Text(context.getString(R.string.event_creation_placeholder_time_input)) }, - label = { Text(timeString) }) - } - - if (isDatePickerVisible) { - DatePickerModal( - onDateSelected = { - selectedDate = it - isDatePickerVisible = false - }, - onDismiss = { isDatePickerVisible = false }) - } - - if (isTimePickerVisible) { - TimePickerModal( - onTimeSelected = { - selectedTime = it - isTimePickerVisible = false - }, - onDismiss = { isTimePickerVisible = false }) - } - - if (selectedDate != null && selectedTime != null) { - onTimestamp(Timestamp(Date(selectedDate!! + selectedTime!!))) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DatePickerModal(onDateSelected: (Long?) -> Unit, onDismiss: () -> Unit) { - val datePickerState = rememberDatePickerState() - val context = LocalContext.current - - DatePickerDialog( - onDismissRequest = onDismiss, - confirmButton = { - TextButton( - onClick = { - onDateSelected(datePickerState.selectedDateMillis) - onDismiss() - }) { - Text(context.getString(R.string.event_creation_dialog_ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(context.getString(R.string.event_creation_dialog_cancel)) - } - }) { - DatePicker(state = datePickerState) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TimePickerModal(onTimeSelected: (Long?) -> Unit, onDismiss: () -> Unit) { - val timePickerState = rememberTimePickerState(is24Hour = true) - - TimePickerDialog( - onDismiss = onDismiss, - onConfirm = { - onTimeSelected( - timePickerState.hour * 60 * 60 * 1000L - 3600000 + timePickerState.minute * 60 * 1000L) - onDismiss() - }) { - TimeInput(state = timePickerState) - } -} - -/** - * A Dialog that is the analog of the DatePickerDialog, but for TimePicker as it currently does not - * exist in the Material3 library. - */ -@Composable -private fun TimePickerDialog( - onDismiss: () -> Unit, - onConfirm: () -> Unit, - content: @Composable () -> Unit -) { - val context = LocalContext.current - AlertDialog( - onDismissRequest = onDismiss, - dismissButton = { - TextButton(onClick = { onDismiss() }) { - Text(context.getString(R.string.event_creation_dialog_cancel)) - } - }, - confirmButton = { - TextButton(onClick = { onConfirm() }) { - Text(context.getString(R.string.event_creation_dialog_ok)) - } - }, - text = { content() }) -} - -fun convertMillisToDate(millis: Long): String { - val formatter = SimpleDateFormat(DAY_MONTH_YEAR_FORMAT, Locale.getDefault()) - return formatter.format(Date(millis)) -} - -fun convertMillisToTime(millis: Long): String { - val formatter = SimpleDateFormat(HOUR_MINUTE_FORMAT, Locale.getDefault()) - return formatter.format(Date(millis)) -} diff --git a/app/src/main/java/com/android/unio/ui/event/EventEdit.kt b/app/src/main/java/com/android/unio/ui/event/EventEdit.kt new file mode 100644 index 000000000..92da152ed --- /dev/null +++ b/app/src/main/java/com/android/unio/ui/event/EventEdit.kt @@ -0,0 +1,350 @@ +package com.android.unio.ui.event + +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.android.unio.R +import com.android.unio.model.association.Association +import com.android.unio.model.association.AssociationViewModel +import com.android.unio.model.event.Event +import com.android.unio.model.event.EventViewModel +import com.android.unio.model.firestore.firestoreReferenceListWith +import com.android.unio.model.map.Location +import com.android.unio.model.search.SearchViewModel +import com.android.unio.model.strings.test_tags.EventEditTestTags +import com.android.unio.model.user.ImageUriType +import com.android.unio.model.user.checkImageUri +import com.android.unio.ui.components.AssociationChips +import com.android.unio.ui.components.BannerImagePicker +import com.android.unio.ui.components.DateAndTimePicker +import com.android.unio.ui.components.getHHMMInMillisFromTimestamp +import com.android.unio.ui.event.overlay.AssociationsOverlay +import com.android.unio.ui.navigation.NavigationAction +import com.android.unio.ui.theme.AppTypography +import com.google.firebase.Timestamp + +/** + * Composable function that displays the event edit screen. It functions similarly to the Event + * Creation screen, but pre-populates the fields with the event's current data. + * + * @param navigationAction The navigation actions to be performed. + * @param searchViewModel The [SearchViewModel] that provides search functionality. + * @param associationViewModel The [AssociationViewModel] that provides association data. + * @param eventViewModel The [EventViewModel] that provides event data. + */ +@Composable +fun EventEditScreen( + navigationAction: NavigationAction, + searchViewModel: SearchViewModel, + associationViewModel: AssociationViewModel, + eventViewModel: EventViewModel +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + var showCoauthorsOverlay by remember { mutableStateOf(false) } + var showTaggedOverlay by remember { mutableStateOf(false) } + + val eventToEdit = remember { eventViewModel.selectedEvent.value!! } + + var name by remember { mutableStateOf(eventToEdit.title) } + var shortDescription by remember { mutableStateOf(eventToEdit.catchyDescription) } + var longDescription by remember { mutableStateOf(eventToEdit.description) } + + var coauthorsAndBoolean = + associationViewModel.associations.collectAsState().value.map { + it to + (if (eventToEdit.organisers.contains(it.uid)) mutableStateOf(true) + else mutableStateOf(false)) + } + + var taggedAndBoolean = + associationViewModel.associations.collectAsState().value.map { + it to + (if (eventToEdit.taggedAssociations.contains(it.uid)) mutableStateOf(true) + else mutableStateOf(false)) + } + + var startTimestamp: Timestamp? by remember { mutableStateOf(eventToEdit.startDate) } + var endTimestamp: Timestamp? by remember { mutableStateOf(eventToEdit.endDate) } + + val initialStartTime = getHHMMInMillisFromTimestamp(eventToEdit.startDate) + val initialStartDate = eventToEdit.startDate.toDate().time - initialStartTime + + val initialEndTime = getHHMMInMillisFromTimestamp(eventToEdit.endDate) + val initialEndDate = eventToEdit.endDate.toDate().time - initialEndTime + + val eventBannerUri = remember { mutableStateOf(eventToEdit.image.toUri()) } + + Scaffold(modifier = Modifier.testTag(EventEditTestTags.SCREEN)) { padding -> + Column( + modifier = + Modifier.padding(padding).padding(20.dp).fillMaxWidth().verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = CenterHorizontally) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + IconButton(onClick = { navigationAction.goBack() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = context.getString(R.string.event_creation_cancel_button)) + } + Text( + context.getString(R.string.event_edit_title) + " " + eventToEdit.title, + style = AppTypography.headlineSmall, + modifier = Modifier.testTag(EventEditTestTags.TITLE)) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().testTag(EventEditTestTags.EVENT_TITLE), + value = name, + onValueChange = { name = it }, + label = { Text(context.getString(R.string.event_creation_name_label)) }) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().testTag(EventEditTestTags.SHORT_DESCRIPTION), + value = shortDescription, + onValueChange = { shortDescription = it }, + label = { Text(context.getString(R.string.event_creation_short_description_label)) }) + + BannerImagePicker( + eventBannerUri, modifier = Modifier.testTag(EventEditTestTags.EVENT_IMAGE)) + + OutlinedButton( + modifier = Modifier.fillMaxWidth().testTag(EventEditTestTags.COAUTHORS), + onClick = { showCoauthorsOverlay = true }) { + Icon( + Icons.Default.Add, + contentDescription = + context.getString(R.string.social_overlay_content_description_add)) + Text(context.getString(R.string.event_creation_coauthors_label)) + } + + AssociationChips(coauthorsAndBoolean) + + OutlinedButton( + modifier = Modifier.fillMaxWidth().testTag(EventEditTestTags.TAGGED_ASSOCIATIONS), + onClick = { showTaggedOverlay = true }) { + Icon( + Icons.Default.Add, + contentDescription = + context.getString(R.string.social_overlay_content_description_add)) + Text(context.getString(R.string.event_creation_tagged_label)) + } + + AssociationChips(taggedAndBoolean) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().testTag(EventEditTestTags.DESCRIPTION), + value = longDescription, + onValueChange = { longDescription = it }, + label = { Text(context.getString(R.string.event_creation_description_label)) }) + + DateAndTimePicker( + context.getString(R.string.event_creation_startdate_label), + context.getString(R.string.event_creation_starttime_label), + modifier = Modifier.testTag(EventEditTestTags.START_TIME), + initialDate = initialStartDate, + initialTime = initialStartTime, + EventEditTestTags.START_DATE_FIELD, + EventEditTestTags.START_TIME_FIELD, + EventEditTestTags.START_DATE_PICKER, + EventEditTestTags.START_TIME_PICKER) { + startTimestamp = it + } + + DateAndTimePicker( + context.getString(R.string.event_creation_enddate_label), + context.getString(R.string.event_creation_endtime_label), + modifier = Modifier.testTag(EventEditTestTags.END_TIME), + initialDate = initialEndDate, + initialTime = initialEndTime, + EventEditTestTags.END_DATE_FIELD, + EventEditTestTags.END_TIME_FIELD, + EventEditTestTags.END_DATE_PICKER, + EventEditTestTags.END_TIME_PICKER) { + endTimestamp = it + } + if (startTimestamp != null && endTimestamp != null) { + if (startTimestamp!! > endTimestamp!!) { + Text( + text = context.getString(R.string.event_creation_end_before_start), + modifier = Modifier.testTag(EventEditTestTags.ERROR_TEXT1), + color = MaterialTheme.colorScheme.error) + } + if (startTimestamp!! == endTimestamp!!) { + Text( + text = context.getString(R.string.event_creation_end_equals_start), + modifier = Modifier.testTag(EventEditTestTags.ERROR_TEXT2), + color = MaterialTheme.colorScheme.error) + } + } + + OutlinedTextField( + modifier = + Modifier.fillMaxWidth().testTag(EventEditTestTags.LOCATION).clickable { + Toast.makeText(context, "Location is not implemented yet", Toast.LENGTH_SHORT) + .show() + }, + value = "", + onValueChange = {}, + label = { Text(context.getString(R.string.event_creation_location_label)) }) + + Spacer(modifier = Modifier.width(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically) { + Button( + modifier = Modifier.testTag(EventEditTestTags.DELETE_BUTTON), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error), + onClick = { + // A dialog should be added to prevent accidental deletion + eventViewModel.deleteEvent( + eventToEdit, + onSuccess = { navigationAction.goBack() }, + onFailure = { + Toast.makeText( + context, + context.getString(R.string.event_edit_failed), + Toast.LENGTH_SHORT) + .show() + }) + }) { + Text(context.getString(R.string.event_edit_delete_button)) + } + + Button( + modifier = Modifier.testTag(EventEditTestTags.SAVE_BUTTON), + enabled = + name.isNotEmpty() && + shortDescription.isNotEmpty() && + longDescription.isNotEmpty() && + startTimestamp != null && + endTimestamp != null && + startTimestamp!! < endTimestamp!! && + eventBannerUri.value != Uri.EMPTY, + onClick = { + val updatedEvent = + Event( + uid = eventToEdit.uid, + title = name, + organisers = + Association.firestoreReferenceListWith( + (coauthorsAndBoolean + .filter { it.second.value } + .map { it.first.uid } + + associationViewModel.selectedAssociation.value!!.uid) + .distinct()), + taggedAssociations = + Association.firestoreReferenceListWith( + taggedAndBoolean + .filter { it.second.value } + .map { it.first.uid }), + image = eventBannerUri.value.toString(), + description = longDescription, + catchyDescription = shortDescription, + price = 0.0, + startDate = startTimestamp!!, + endDate = endTimestamp!!, + location = Location(), + ) + // This should be extracted to a util + if (checkImageUri(eventBannerUri.toString()) == ImageUriType.LOCAL) { + val inputStream = + context.contentResolver.openInputStream(eventBannerUri.value)!! + eventViewModel.updateEvent( + inputStream, + updatedEvent, + onSuccess = { navigationAction.goBack() }, + onFailure = { + Toast.makeText( + context, + context.getString(R.string.event_creation_failed), + Toast.LENGTH_SHORT) + .show() + }) + } else { + eventViewModel.updateEventWithoutImage( + updatedEvent, + onSuccess = { navigationAction.goBack() }, + onFailure = { + Toast.makeText( + context, + context.getString(R.string.event_creation_failed), + Toast.LENGTH_SHORT) + .show() + }) + } + }) { + Text(context.getString(R.string.event_edit_save_button)) + } + } + Spacer(modifier = Modifier.width(10.dp)) + + if (showCoauthorsOverlay) { + AssociationsOverlay( + onDismiss = { showCoauthorsOverlay = false }, + onSave = { coauthors -> + coauthorsAndBoolean = coauthors + showCoauthorsOverlay = false + }, + associations = coauthorsAndBoolean, + searchViewModel = searchViewModel, + headerText = context.getString(R.string.associations_overlay_coauthors_title), + bodyText = context.getString(R.string.associations_overlay_coauthors_description)) + } + + if (showTaggedOverlay) { + AssociationsOverlay( + onDismiss = { showTaggedOverlay = false }, + onSave = { tagged -> + taggedAndBoolean = tagged + showTaggedOverlay = false + }, + associations = taggedAndBoolean, + searchViewModel = searchViewModel, + headerText = context.getString(R.string.associations_overlay_tagged_title), + bodyText = context.getString(R.string.associations_overlay_tagged_description)) + } + } + } +} diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4d8e1475d..55cebc8a2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -177,6 +177,12 @@ Plus… + + Modifier + Supprimer + Echec lors de la modification + Sauvegarder + Co-organisateurs Choisir quels associations organisent l\'évènement avec vous diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3c916710..10fbd7c0d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -181,6 +181,15 @@ More… + + Edit + Delete + Failed to edit event + Save + + + Select date + Select time Coauthors diff --git a/app/src/test/java/com/android/unio/model/event/EventViewModelTest.kt b/app/src/test/java/com/android/unio/model/event/EventViewModelTest.kt index 8d30e9f21..934620f0f 100644 --- a/app/src/test/java/com/android/unio/model/event/EventViewModelTest.kt +++ b/app/src/test/java/com/android/unio/model/event/EventViewModelTest.kt @@ -2,6 +2,7 @@ package com.android.unio.model.event import androidx.test.core.app.ApplicationProvider import com.android.unio.mocks.event.MockEvent +import com.android.unio.model.association.AssociationRepositoryFirestore import com.android.unio.model.image.ImageRepositoryFirebaseStorage import com.google.firebase.FirebaseApp import com.google.firebase.Timestamp @@ -34,6 +35,7 @@ class EventViewModelTest { @Mock private lateinit var inputStream: InputStream @MockK lateinit var imageRepository: ImageRepositoryFirebaseStorage + @MockK private lateinit var associationRepositoryFirestore: AssociationRepositoryFirestore private lateinit var eventViewModel: EventViewModel @@ -68,11 +70,14 @@ class EventViewModelTest { onSuccess("url") } - eventViewModel = EventViewModel(repository, imageRepository) + every { associationRepositoryFirestore.getAssociations(any(), any()) } answers {} + every { associationRepositoryFirestore.saveAssociation(any(), any(), any()) } answers {} + + eventViewModel = EventViewModel(repository, imageRepository, associationRepositoryFirestore) } @Test - fun addEventTest() { + fun addEventandUpdateTest() { val event = testEvents.get(0) `when`(repository.addEvent(eq(event), any(), any())).thenAnswer { invocation -> val onSuccess = invocation.arguments[1] as () -> Unit @@ -83,6 +88,39 @@ class EventViewModelTest { inputStream, event, { verify(repository).addEvent(eq(event), any(), any()) }, {}) } + @Test + fun updateEventTest() { + val event = testEvents.get(0) + `when`(repository.addEvent(eq(event), any(), any())).thenAnswer { invocation -> + val onSuccess = invocation.arguments[1] as () -> Unit + onSuccess() + } + eventViewModel.updateEvent( + inputStream, event, { verify(repository).addEvent(eq(event), any(), any()) }, {}) + } + + @Test + fun updateEventWithoutImageTest() { + val event = testEvents.get(0) + `when`(repository.addEvent(eq(event), any(), any())).thenAnswer { invocation -> + val onSuccess = invocation.arguments[1] as () -> Unit + onSuccess() + } + eventViewModel.updateEventWithoutImage( + event, { verify(repository).addEvent(eq(event), any(), any()) }, {}) + } + + @Test + fun deleteEventTest() { + val event = testEvents.get(0) + `when`(repository.deleteEventById(eq(event.uid), any(), any())).thenAnswer { invocation -> + val onSuccess = invocation.arguments[1] as () -> Unit + onSuccess() + } + eventViewModel.deleteEvent( + event, { verify(repository).deleteEventById(eq(event.uid), any(), any()) }, {}) + } + @Test fun testFindEventById() { `when`(repository.getEvents(any(), any())).thenAnswer { invocation ->