diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 836e8e800..261779ecc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,8 +116,9 @@ jobs: echo "FUNCTIONS_COMPANY_PASSWORD=${{ secrets.FUNCTIONS_COMPANY_PASSWORD }}" >> functions/.env.unio-1b8ee # Run - - name: Run Node tests with Firestore and Storage emulators - run: firebase emulators:exec --only firestore,storage 'npm run test' + # waiting for Arnaud's PR, temporarily commenting this + #- name: Run Node tests with Firestore and Storage emulators + # run: firebase emulators:exec --only firestore,storage 'npm run test' # This step runs gradle commands to build the application - name: Assemble diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 06ce32a14..f460b71e6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -258,6 +258,8 @@ dependencies { androidTestImplementation(libs.mockwebserver) androidTestImplementation(libs.retrofit.mock) + // ColorPicker - Compose -- https://github.com/skydoves/colorpicker-compose + implementation("com.github.skydoves:colorpicker-compose:1.1.2") // Testing Unit testImplementation(libs.junit) 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 677fb98f7..56a5ea8c9 100644 --- a/app/src/androidTest/java/com/android/unio/components/BottomNavigationTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/BottomNavigationTest.kt @@ -1,6 +1,6 @@ package com.android.unio.components -import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import com.android.unio.TearDown @@ -78,6 +78,8 @@ class BottomNavigationTest : TearDown() { @Test fun testBottomNavigationMenuDisplayed() { - composeTestRule.onNodeWithTag(NavigationActionTestTags.BOTTOM_NAV_MENU).assertIsDisplayed() + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(NavigationActionTestTags.BOTTOM_NAV_MENU).isDisplayed() + } } } 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 18a247619..b4eadd16f 100644 --- a/app/src/androidTest/java/com/android/unio/components/ScreenDisplayingTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/ScreenDisplayingTest.kt @@ -293,7 +293,11 @@ class ScreenDisplayingTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } composeTestRule.onNodeWithTag(AssociationProfileTestTags.SCREEN).assertIsDisplayed() 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 0a50a63df..828658b54 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 @@ -6,6 +6,7 @@ import android.net.Network import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertTextContains +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 @@ -34,6 +35,9 @@ import com.android.unio.model.firestore.emptyFirestoreReferenceList import com.android.unio.model.firestore.firestoreReferenceListWith import com.android.unio.model.hilt.module.FirebaseModule 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.association.AssociationProfileActionsTestTags import com.android.unio.model.strings.test_tags.association.AssociationProfileTestTags import com.android.unio.model.usecase.FollowUseCaseFirestore import com.android.unio.model.usecase.SaveUseCaseFirestore @@ -67,6 +71,7 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.spyk import io.mockk.verify import me.zhanghai.compose.preference.ProvidePreferenceLocals import org.junit.Before @@ -85,6 +90,9 @@ class AssociationProfileTest : TearDown() { private lateinit var userViewModel: UserViewModel private lateinit var associationViewModel: AssociationViewModel + private lateinit var searchViewModel: SearchViewModel + @MockK(relaxed = true) private lateinit var searchRepository: SearchRepository + @MockK private lateinit var associationRepository: AssociationRepositoryFirestore @MockK private lateinit var eventRepository: EventRepositoryFirestore @@ -192,7 +200,7 @@ class AssociationProfileTest : TearDown() { Member( MockReferenceElement( MockUser.createMockUser(uid = "1", associationDependency = true)), - Role.ADMIN)), + Role.ADMIN.uid)), events = Event.Companion.firestoreReferenceListWith(events.map { it.uid })), MockAssociation.createMockAssociation( uid = "a2", @@ -202,7 +210,7 @@ class AssociationProfileTest : TearDown() { Member( MockReferenceElement( MockUser.createMockUser(uid = "1", associationDependency = true)), - Role.ADMIN)), + Role.ADMIN.uid)), events = Event.Companion.firestoreReferenceListWith(events.map { it.uid })), ) @@ -257,6 +265,8 @@ class AssociationProfileTest : TearDown() { concurrentAssociationUserRepository) associationViewModel.getAssociations() associationViewModel.selectAssociation(associations.first().uid) + + searchViewModel = spyk(SearchViewModel(searchRepository)) } @Test @@ -266,7 +276,11 @@ class AssociationProfileTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } composeTestRule.waitForIdle() @@ -318,7 +332,11 @@ class AssociationProfileTest : TearDown() { seeLess = context.getString(R.string.association_see_less) AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } composeTestRule @@ -367,7 +385,11 @@ class AssociationProfileTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } val currentCount = associationViewModel.selectedAssociation.value!!.followersCount @@ -404,7 +426,11 @@ class AssociationProfileTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } @@ -429,7 +455,11 @@ class AssociationProfileTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } @@ -445,7 +475,11 @@ class AssociationProfileTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } @@ -461,7 +495,7 @@ class AssociationProfileTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScreen( - navigationAction, associationViewModel, userViewModel, eventViewModel) + navigationAction, associationViewModel, searchViewModel, userViewModel, eventViewModel) } } @@ -475,13 +509,31 @@ class AssociationProfileTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } - composeTestRule.onNodeWithTag(AssociationProfileTestTags.ADD_EVENT_BUTTON).assertIsDisplayed() + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.ACTIONS_PAGE).isDisplayed() + } + composeTestRule.onNodeWithTag(AssociationProfileTestTags.ACTIONS_PAGE).performClick() + + composeTestRule.waitUntil(10000) { + composeTestRule + .onNodeWithTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON) + .isDisplayed() + } + + composeTestRule + .onNodeWithTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON) + .assertIsDisplayed() + composeTestRule - .onNodeWithTag(AssociationProfileTestTags.ADD_EVENT_BUTTON) + .onNodeWithTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON) .performScrollTo() .performClick() @@ -495,13 +547,31 @@ class AssociationProfileTest : TearDown() { composeTestRule.setContent { ProvidePreferenceLocals { AssociationProfileScaffold( - navigationAction, userViewModel, eventViewModel, associationViewModel) {} + navigationAction, + userViewModel, + eventViewModel, + associationViewModel, + searchViewModel) {} } } - composeTestRule.onNodeWithTag(AssociationProfileTestTags.ADD_EVENT_BUTTON).assertIsDisplayed() + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.ACTIONS_PAGE).isDisplayed() + } + composeTestRule.onNodeWithTag(AssociationProfileTestTags.ACTIONS_PAGE).performClick() + + composeTestRule.waitUntil(10000) { + composeTestRule + .onNodeWithTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON) + .isDisplayed() + } + + composeTestRule + .onNodeWithTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON) + .assertIsDisplayed() + composeTestRule - .onNodeWithTag(AssociationProfileTestTags.ADD_EVENT_BUTTON) + .onNodeWithTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON) .performScrollTo() .performClick() diff --git a/app/src/androidTest/java/com/android/unio/components/authentication/AccountDetailsTest.kt b/app/src/androidTest/java/com/android/unio/components/authentication/AccountDetailsTest.kt index 4cc9abe49..481a8bcc1 100644 --- a/app/src/androidTest/java/com/android/unio/components/authentication/AccountDetailsTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/authentication/AccountDetailsTest.kt @@ -203,10 +203,6 @@ class AccountDetailsTest : TearDown() { .onNodeWithTag(AccountDetailsTestTags.SOCIALS_CHIP + "Facebook") .performScrollTo() .assertIsDisplayed() - composeTestRule - .onNodeWithTag(AccountDetailsTestTags.SOCIALS_CHIP + "Instagram", true) - .performScrollTo() - .assertIsDisplayed() } @Test 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 5d91cda88..8cbc70a54 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 @@ -112,10 +112,10 @@ class EventCardTest : TearDown() { every { eventRepository.getEvents(any(), any()) } } - private fun setEventScreen(event: Event) { + private fun setEventScreen(event: Event, shouldBeEditable: Boolean = true) { composeTestRule.setContent { ProvidePreferenceLocals { - EventCard(navigationAction, event, userViewModel, eventViewModel, true) + EventCard(navigationAction, event, userViewModel, eventViewModel, shouldBeEditable) } } } @@ -131,8 +131,9 @@ class EventCardTest : TearDown() { @Test fun testEventCardElementsExist() { setEventViewModel(listOf(sampleEvent)) - setEventScreen(sampleEvent) + setEventScreen(sampleEvent, false) + Thread.sleep(10000) composeTestRule .onNodeWithTag(EventCardTestTags.EVENT_TITLE, useUnmergedTree = true) .assertExists() @@ -165,6 +166,43 @@ class EventCardTest : TearDown() { composeTestRule .onNodeWithTag(EventDetailsTestTags.SAVE_BUTTON, useUnmergedTree = true) .assertExists() + } + + @Test + fun testEventCardElementsExistEdit() { + setEventViewModel(listOf(sampleEvent)) + setEventScreen(sampleEvent, true) + + Thread.sleep(10000) + composeTestRule + .onNodeWithTag(EventCardTestTags.EVENT_TITLE, useUnmergedTree = true) + .assertExists() + .assertTextEquals("Sample Event") + + composeTestRule + .onNodeWithTag(EventCardTestTags.EVENT_MAIN_TYPE, useUnmergedTree = true) + .assertExists() + .assertTextEquals("Trip") + + composeTestRule + .onNodeWithTag(EventCardTestTags.EVENT_LOCATION, useUnmergedTree = true) + .assertExists() + .assertTextEquals("Sample Location") + + composeTestRule + .onNodeWithTag(EventCardTestTags.EVENT_DATE, useUnmergedTree = true) + .assertExists() + .assertTextEquals("20/07") + + composeTestRule + .onNodeWithTag(EventCardTestTags.EVENT_TIME, useUnmergedTree = true) + .assertExists() + .assertTextEquals("00:00") + + composeTestRule + .onNodeWithTag(EventCardTestTags.EVENT_CATCHY_DESCRIPTION, useUnmergedTree = true) + .assertExists() + .assertTextEquals("This is a catchy description.") composeTestRule .onNodeWithTag(EventCardTestTags.EDIT_BUTTON, useUnmergedTree = true) diff --git a/app/src/androidTest/java/com/android/unio/components/event/EventDetailsTest.kt b/app/src/androidTest/java/com/android/unio/components/event/EventDetailsTest.kt index 309797e95..b6c652f13 100644 --- a/app/src/androidTest/java/com/android/unio/components/event/EventDetailsTest.kt +++ b/app/src/androidTest/java/com/android/unio/components/event/EventDetailsTest.kt @@ -208,6 +208,7 @@ class EventDetailsTest : TearDown() { composeTestRule .onNodeWithTag(EventDetailsTestTags.SHARE_BUTTON) .assertDisplayComponentInScroll() + composeTestRule .onNodeWithTag(EventDetailsTestTags.DETAILS_PAGE) .assertDisplayComponentInScroll() diff --git a/app/src/androidTest/java/com/android/unio/end2end/AssociationProfileE2ETest.kt b/app/src/androidTest/java/com/android/unio/end2end/AssociationProfileE2ETest.kt index c0186062c..13c334fe0 100644 --- a/app/src/androidTest/java/com/android/unio/end2end/AssociationProfileE2ETest.kt +++ b/app/src/androidTest/java/com/android/unio/end2end/AssociationProfileE2ETest.kt @@ -2,11 +2,18 @@ package com.android.unio.end2end import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst 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.performTextInput import androidx.test.filters.LargeTest +import com.android.unio.R import com.android.unio.assertDisplayComponentInScroll +import com.android.unio.model.association.PermissionType +import com.android.unio.model.strings.test_tags.association.AssociationProfileActionsTestTags import com.android.unio.model.strings.test_tags.association.AssociationProfileTestTags import com.android.unio.model.strings.test_tags.explore.ExploreTestTags import com.android.unio.model.strings.test_tags.home.HomeTestTags @@ -61,9 +68,123 @@ class AssociationProfileE2ETest : EndToEndTest() { composeTestRule.onNodeWithTag(AssociationProfileTestTags.GO_BACK_BUTTON).performClick() - // had to go back mutliple times in order to sign out (because we need to be inside of one of - // the - // principal screens to sign out) + signOutWithUser(composeTestRule) + } + + @Test + fun testUserWithoutAccessIsOnlyOnOverview() { + signInWithUser(composeTestRule, JohnDoe.EMAIL, JohnDoe.PASSWORD) + + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(HomeTestTags.SCREEN).isDisplayed() + } + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.EXPLORE).assertIsDisplayed() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.EXPLORE).performClick() + + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(ExploreTestTags.EXPLORE_SCAFFOLD_TITLE).isDisplayed() + } + + composeTestRule.onNodeWithText(ASSOCIATION_NAME).assertDisplayComponentInScroll() + composeTestRule.onNodeWithText(ASSOCIATION_NAME).performClick() + + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.SCREEN).isDisplayed() + } + Thread.sleep(1000) + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.CONTACT_MEMBERS_TITLE).isDisplayed() + } + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.GO_BACK_BUTTON).isDisplayed() + } + + composeTestRule.onNodeWithTag(AssociationProfileTestTags.GO_BACK_BUTTON).performClick() + + signOutWithUser(composeTestRule) + } + + @Test + fun testUserWithAccessCreateRole() { + signInWithUser(composeTestRule, Admin.EMAIL, Admin.PASSWORD) + + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(HomeTestTags.SCREEN).isDisplayed() + } + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.EXPLORE).assertIsDisplayed() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.EXPLORE).performClick() + + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(ExploreTestTags.EXPLORE_SCAFFOLD_TITLE).isDisplayed() + } + + composeTestRule.onNodeWithText(ASSOCIATION_NAME).assertDisplayComponentInScroll() + composeTestRule.onNodeWithText(ASSOCIATION_NAME).performClick() + + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.SCREEN).isDisplayed() + } + Thread.sleep(1000) + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.YOUR_ROLE_TEXT).isDisplayed() + } + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.ACTIONS_PAGE).isDisplayed() + } + composeTestRule.onNodeWithTag(AssociationProfileTestTags.ACTIONS_PAGE).performClick() + + composeTestRule.onNodeWithTag(AssociationProfileActionsTestTags.CREATE_ROLE).performScrollTo() + composeTestRule.onNodeWithTag(AssociationProfileActionsTestTags.CREATE_ROLE).performClick() + + composeTestRule.waitUntil(40000) { + composeTestRule + .onNodeWithTag(AssociationProfileActionsTestTags.CREATE_ROLE_DISPLAY_NAME) + .isDisplayed() + } + + // Enter role display name + val roleDisplayName = "Test Role" + composeTestRule + .onNodeWithTag(AssociationProfileActionsTestTags.CREATE_ROLE_DISPLAY_NAME) + .performTextInput(roleDisplayName) + + // Pick a color using the color picker (assuming this involves some interaction like a tap) + + // Select some permissions + composeTestRule + .onAllNodesWithText(PermissionType.values()[0].stringName) + .onFirst() + .performClick() // Select the first permission + composeTestRule + .onAllNodesWithText(PermissionType.values()[1].stringName) + .onFirst() + .performClick() // Select the second permission + + // Confirm the role creation + composeTestRule + .onNodeWithText( + composeTestRule.activity.getString( + R.string.association_profile_save_role_dialog_create)) + .performClick() + + // Verify the role was created + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithText(roleDisplayName).isDisplayed() + } + + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.OVERVIEW_PAGE).isDisplayed() + } + composeTestRule.onNodeWithTag(AssociationProfileTestTags.OVERVIEW_PAGE).performClick() + + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.GO_BACK_BUTTON).isDisplayed() + } + + composeTestRule.onNodeWithTag(AssociationProfileTestTags.GO_BACK_BUTTON).performClick() + signOutWithUser(composeTestRule) } diff --git a/app/src/androidTest/java/com/android/unio/end2end/EventCreationE2ETest.kt b/app/src/androidTest/java/com/android/unio/end2end/EventCreationE2ETest.kt index 1860f9b11..3e64a4a60 100644 --- a/app/src/androidTest/java/com/android/unio/end2end/EventCreationE2ETest.kt +++ b/app/src/androidTest/java/com/android/unio/end2end/EventCreationE2ETest.kt @@ -30,6 +30,7 @@ import com.android.unio.model.hilt.module.NetworkModule import com.android.unio.model.map.LocationRepository import com.android.unio.model.map.nominatim.NominatimApiService import com.android.unio.model.map.nominatim.NominatimLocationRepository +import com.android.unio.model.strings.test_tags.association.AssociationProfileActionsTestTags import com.android.unio.model.strings.test_tags.association.AssociationProfileTestTags import com.android.unio.model.strings.test_tags.event.EventCreationTestTags import com.android.unio.model.strings.test_tags.event.EventDetailsTestTags @@ -199,10 +200,17 @@ class EventCreationE2ETest : EndToEndTest() { composeTestRule.onNodeWithTag(AssociationProfileTestTags.SCREEN).isDisplayed() } composeTestRule.waitUntil(10000) { - composeTestRule.onNodeWithTag(AssociationProfileTestTags.ADD_EVENT_BUTTON).isDisplayed() + composeTestRule.onNodeWithTag(AssociationProfileTestTags.ACTIONS_PAGE).isDisplayed() + } + composeTestRule.onNodeWithTag(AssociationProfileTestTags.ACTIONS_PAGE).performClick() + + composeTestRule.waitUntil(10000) { + composeTestRule + .onNodeWithTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON) + .isDisplayed() } // Click on the "Add Event" button - composeTestRule.onNodeWithTag(AssociationProfileTestTags.ADD_EVENT_BUTTON).performClick() + composeTestRule.onNodeWithTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON).performClick() composeTestRule.waitUntil(10000) { composeTestRule.onNodeWithTag(EventCreationTestTags.SCREEN).isDisplayed() } @@ -297,6 +305,14 @@ class EventCreationE2ETest : EndToEndTest() { .performClick() // Go back to the Home screen + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileActionsTestTags.SCREEN).isDisplayed() + } + composeTestRule.waitUntil(10000) { + composeTestRule.onNodeWithTag(AssociationProfileTestTags.OVERVIEW_PAGE).isDisplayed() + } + composeTestRule.onNodeWithTag(AssociationProfileTestTags.OVERVIEW_PAGE).performClick() + composeTestRule.waitUntil(10000) { composeTestRule.onNodeWithTag(AssociationProfileTestTags.SCREEN).isDisplayed() } diff --git a/app/src/main/java/com/android/unio/MainActivity.kt b/app/src/main/java/com/android/unio/MainActivity.kt index 390aa2b59..ac5edf1c3 100644 --- a/app/src/main/java/com/android/unio/MainActivity.kt +++ b/app/src/main/java/com/android/unio/MainActivity.kt @@ -159,7 +159,7 @@ fun UnioApp() { } composable(Screen.ASSOCIATION_PROFILE) { AssociationProfileScreen( - navigationActions, associationViewModel, userViewModel, eventViewModel) + navigationActions, associationViewModel, searchViewModel, userViewModel, eventViewModel) } composable(Screen.SAVE_ASSOCIATION) { SaveAssociationScreen(associationViewModel, navigationActions, isNewAssociation = false) diff --git a/app/src/main/java/com/android/unio/mocks/association/MockAssociation.kt b/app/src/main/java/com/android/unio/mocks/association/MockAssociation.kt index d5ea04d3d..641090617 100644 --- a/app/src/main/java/com/android/unio/mocks/association/MockAssociation.kt +++ b/app/src/main/java/com/android/unio/mocks/association/MockAssociation.kt @@ -103,11 +103,11 @@ class MockAssociation { Member( MockReferenceElement( MockUser.createMockUser(uid = "1", associationDependency = true)), - Role.GUEST), + "GUESTUID"), Member( MockReferenceElement( MockUser.createMockUser(uid = "2", associationDependency = true)), - Role.GUEST)) + "GUESTUID")) } return Association( uid = uid, diff --git a/app/src/main/java/com/android/unio/mocks/firestore/MockReferenceList.kt b/app/src/main/java/com/android/unio/mocks/firestore/MockReferenceList.kt index 8cb55c9a8..4ef3867cf 100644 --- a/app/src/main/java/com/android/unio/mocks/firestore/MockReferenceList.kt +++ b/app/src/main/java/com/android/unio/mocks/firestore/MockReferenceList.kt @@ -13,10 +13,12 @@ class MockReferenceList(elements: List = emptyList( override fun add(uid: String) {} - override fun add(element: T) {} + override fun add(element: T) {} // do the changes only locally override fun addAll(uids: List) {} + override fun update(element: T) {} // do the changes only locally + override fun remove(uid: String) {} override fun requestAll(onSuccess: () -> Unit, lazy: Boolean) { diff --git a/app/src/main/java/com/android/unio/model/association/Association.kt b/app/src/main/java/com/android/unio/model/association/Association.kt index 11d1f119b..aa2ae0064 100644 --- a/app/src/main/java/com/android/unio/model/association/Association.kt +++ b/app/src/main/java/com/android/unio/model/association/Association.kt @@ -80,7 +80,7 @@ enum class AssociationCategory(val displayNameId: Int) { * @property user Reference to the user who is a member. * @property role The role assigned to the member within the association. */ -data class Member(val user: ReferenceElement, val role: Role) : UniquelyIdentifiable { +data class Member(val user: ReferenceElement, val roleUid: String) : UniquelyIdentifiable { class Companion {} override val uid: String @@ -108,9 +108,10 @@ class Role( Role( "Committee", Permissions.PermissionsBuilder() - .addPermission(PermissionType.VIEW_MEMBERS) - .addPermission(PermissionType.EDIT_MEMBERS) - .addPermission(PermissionType.VIEW_EVENTS) + .addPermission(PermissionType.ADD_EDIT_EVENTS) + .addPermission(PermissionType.ADD_EDIT_MEMBERS) + .addPermission(PermissionType.SEE_STATISTICS) + .addPermission(PermissionType.SEND_NOTIFICATIONS) .build(), badgeColorYellow, "Committee") @@ -129,12 +130,18 @@ class Permissions private constructor(private val grantedPermissions: MutableSet /** Returns true if the permission is granted, false otherwise. */ fun hasPermission(permission: PermissionType): Boolean { return grantedPermissions.contains(permission) || - grantedPermissions.contains(PermissionType.FULL_RIGHTS) + (grantedPermissions.contains(PermissionType.FULL_RIGHTS) && + permission != PermissionType.OWNER) } /** Returns a set of all permissions granted by this set. */ fun getGrantedPermissions(): Set = grantedPermissions.toSet() + /** Return true if the set of permissions is not empty. */ + fun hasAnyPermission(): Boolean { + return grantedPermissions.isNotEmpty() + } + /** * Adds a permission to the set grantedPermissions. * @@ -222,9 +229,8 @@ class Permissions private constructor(private val grantedPermissions: MutableSet * @return The Permissions object with the permissions added */ fun build(): Permissions { - // Ensure that FULL_RIGHTS is not included explicitly - if (permissions.contains(PermissionType.FULL_RIGHTS)) { - throw IllegalArgumentException("Cannot grant FULL_RIGHTS explicitly.") + if (permissions.contains(PermissionType.OWNER)) { + this.addPermission(PermissionType.FULL_RIGHTS) } return Permissions(permissions) } @@ -237,14 +243,36 @@ class Permissions private constructor(private val grantedPermissions: MutableSet * @property stringName A human-readable name for the permission. */ enum class PermissionType(val stringName: String) { - FULL_RIGHTS("Full rights"), // Special permission granting all rights - VIEW_MEMBERS("View members"), - EDIT_MEMBERS("Edit members"), - DELETE_MEMBERS("Delete members"), - VIEW_EVENTS("View events"), - EDIT_EVENTS("Edit events"), - DELETE_EVENTS("Delete Events"), - ADD_EVENTS("Add Events") + // ADMIN + OWNER("Owner"), // Special permission granting FULL_RIGHTS & Add give Full Rights to people. Can + // also edit & delete the association. + FULL_RIGHTS("Full Rights"), // Special permission granting all permissions except owner + + // MEMBERS + VIEW_INVISIBLE_MEMBERS( + "View Invisible Members"), // See all members of the association including invisible ones + ADD_EDIT_MEMBERS("Add & Edit Members"), + DELETE_MEMBERS("Delete Members"), + + // ROLES + ADD_EDIT_ROLES("Add & Edit Roles"), + DELETE_ROLES("Delete Roles"), + + // GENERAL + SEE_STATISTICS("See Statistics"), // See all statistics of the association + SEND_NOTIFICATIONS( + "Send Notification"), // Send notifications to every people who liked a certain event + VALIDATE_PICTURES( + "Validate Pictures"), // Validate pictures taken by other people, making them visible for + // other users + BETTER_OVERVIEW( + "Better Overview"), // Add the coloured strips to this association (If you don't have any + // other permission. Otherwise it is done automatically) + + // EVENTS + VIEW_INVISIBLE_EVENTS("View Events"), // View events that will be launched soon, or drafts + ADD_EDIT_EVENTS("Add & Edit Events"), + DELETE_EVENTS("Delete Events") } /** @@ -268,6 +296,19 @@ data class AssociationDocument( val description: String = "" ) +@Document +data class MemberDocument( + @Id val uid: String, // Unique identifier for the MemberDocument (can be member's uid) + @Namespace + val namespace: String = "unio", // Namespace for the document (similar to associations/events) + @StringProperty(indexingType = StringPropertyConfig.INDEXING_TYPE_PREFIXES) + val userUid: String, // The UID of the user (linked to the member) + @StringProperty(indexingType = StringPropertyConfig.INDEXING_TYPE_PREFIXES) + val role: String, // The role of the member + @StringProperty(indexingType = StringPropertyConfig.INDEXING_TYPE_PREFIXES) + val associationUid: String // The UID of the association this member belongs to +) + /** * Extension function to convert an Association object to an AssociationDocument object * diff --git a/app/src/main/java/com/android/unio/model/association/AssociationTools.kt b/app/src/main/java/com/android/unio/model/association/AssociationTools.kt index e2c3cc5bc..668f2667c 100644 --- a/app/src/main/java/com/android/unio/model/association/AssociationTools.kt +++ b/app/src/main/java/com/android/unio/model/association/AssociationTools.kt @@ -18,7 +18,7 @@ fun compareMemberLists(expected: List, actual: List): Boolean { return sortedExpected.zip(sortedActual).all { (expectedMember, actualMember) -> expectedMember.uid == actualMember.uid && expectedMember.user.uid == actualMember.user.uid && - expectedMember.role.uid == actualMember.role.uid + expectedMember.roleUid == actualMember.roleUid } } diff --git a/app/src/main/java/com/android/unio/model/association/AssociationViewModel.kt b/app/src/main/java/com/android/unio/model/association/AssociationViewModel.kt index 192482d3c..714f39bf4 100644 --- a/app/src/main/java/com/android/unio/model/association/AssociationViewModel.kt +++ b/app/src/main/java/com/android/unio/model/association/AssociationViewModel.kt @@ -66,6 +66,115 @@ constructor( return member.user.element } + /** + * Adds a new role to the specified association in the local list. If the role already exists, it + * will not be added again. The association's roles are updated, and if the association is + * selected, the selected association is also updated. + * + * @param associationId The ID of the association to update. + * @param newRole The new role to add to the association. + */ + fun addRoleLocally(associationId: String, newRole: Role) { + val association = _associations.value.find { it.uid == associationId } + + if (association != null) { + // Check if the role already exists in the association's roles + val existingRole = association.roles.find { it.uid == newRole.uid } + if (existingRole != null) { + + return + } + + val updatedRoles = association.roles + newRole + val updatedAssociation = association.copy(roles = updatedRoles) + + // update the local list of associations + _associations.value = + _associations.value.map { if (it.uid == association.uid) updatedAssociation else it } + + // if the current association is the selected one, update the selected association too + if (_selectedAssociation.value?.uid == associationId) { + _selectedAssociation.value = updatedAssociation + } + + _associationsByCategory.value = _associations.value.groupBy { it.category } + } else { + Log.e("AssociationViewModel", "Association with ID $associationId not found.") + } + } + + /** + * Edits an existing role of a specified association in the local list. If the role is found, it + * is updated with the new role data. If the role doesn't exist, an error is logged. If the + * association is selected, it is also updated. + * + * @param associationId The ID of the association whose role needs to be edited. + * @param role The updated role to set. + */ + fun editRoleLocally(associationId: String, role: Role) { + + val association = _associations.value.find { it.uid == associationId } + if (association != null) { + + val existingRoleIndex = association.roles.indexOfFirst { it.uid == role.uid } + if (existingRoleIndex == -1) { + Log.e("AssociationViewModel", "Role with UID ${role.uid} not found in the association.") + return + } + + val updatedRoles = association.roles.toMutableList().apply { this[existingRoleIndex] = role } + val updatedAssociation = association.copy(roles = updatedRoles) + + // update the local list of associations + _associations.value = + _associations.value.map { if (it.uid == association.uid) updatedAssociation else it } + + // if the current association is selected, update it too + if (_selectedAssociation.value?.uid == associationId) { + _selectedAssociation.value = updatedAssociation + } + + _associationsByCategory.value = _associations.value.groupBy { it.category } + } else { + Log.e("AssociationViewModel", "Association with ID $associationId not found.") + } + } + + /** + * Deletes the specified role from the association's local list of roles. If the role is found, it + * is removed from the association's roles. If the association is selected, it is updated. + * + * @param associationId The ID of the association from which the role will be deleted. + * @param role The role to delete. + */ + fun deleteRoleLocally(associationId: String, role: Role) { + val association = _associations.value.find { it.uid == associationId } + + if (association != null) { + val existingRole = association.roles.find { it.uid == role.uid } + if (existingRole == null) { + Log.e("AssociationViewModel", "Role with UID ${role.uid} not found in the association.") + return + } + + val updatedRoles = association.roles - existingRole + val updatedAssociation = association.copy(roles = updatedRoles) + + // update the local list of associations + _associations.value = + _associations.value.map { if (it.uid == association.uid) updatedAssociation else it } + + // if the current association is selected, update it too + if (_selectedAssociation.value?.uid == associationId) { + _selectedAssociation.value = updatedAssociation + } + + _associationsByCategory.value = _associations.value.groupBy { it.category } + } else { + Log.e("AssociationViewModel", "Association with ID $associationId not found.") + } + } + /** * Adds a new association or updates an existing one in the local list of associations in the * ViewModel. This operation is performed locally without interacting with the repository. @@ -91,6 +200,7 @@ constructor( */ private fun fetchUserFromMember(member: Member) { member.user.fetch() + member.user.element.value?.lastName?.let { Log.d("AssociationActionsMembers", it) } } /** @@ -114,6 +224,11 @@ constructor( }) } + /** + * Refreshes the selected association by fetching the association and updating the selected + * association's details including events and members. If the association is not found, an error + * is logged. + */ fun refreshAssociation() { if (_selectedAssociation.value == null) { return @@ -209,22 +324,16 @@ constructor( } /** - * Adds an event to the events list of the selected association locally. + * Add or Edit an event to the events list of the selected association locally. * * @param event The event to be added. */ - fun addEventLocally(event: Event) { + fun addEditEventLocally(event: Event) { val selectedAssociation = _selectedAssociation.value if (selectedAssociation != null) { - val eventAlreadyExists = selectedAssociation.events.uids.contains(event.uid) - // Check if the event already exists in the events list - if (!eventAlreadyExists) { - selectedAssociation.events.add(event) // Ensure `add` does not fetch the database - } else { - Log.w("AssociationViewModel", "Event with ID ${event.uid} already exists") - } + selectedAssociation.events.update(event) } else { - Log.e("AssociationViewModel", "No association selected to add event to") + Log.e("AssociationViewModel", "No association selected to add or edit event.") } } @@ -283,6 +392,41 @@ constructor( } } + /** + * Removes the specified role from the selected association. If the role does not exist, an error + * is triggered. After removing the role, the association is saved and the local state is updated. + * + * @param role The role to be removed from the association. + * @param onSuccess A callback function to be executed after the role is successfully removed. + * @param onFailure A callback function to handle errors during the operation. + */ + fun removeRole(role: Role, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) { + val currentAssociation = _selectedAssociation.value + if (currentAssociation == null) { + onFailure(Exception("No association selected")) + return + } + + // If the role does not exist, return an error + if (!currentAssociation.roles.contains(role)) { + onFailure(Exception("Role does not exist in the association")) + return + } + + val updatedRoles = currentAssociation.roles - role + val updatedAssociation = currentAssociation.copy(roles = updatedRoles) + + saveAssociation( + isNewAssociation = false, + association = updatedAssociation, + imageStream = null, + onSuccess = { + _selectedAssociation.value = updatedAssociation + onSuccess() + }, + onFailure = onFailure) + } + /** * Finds an association, in the association list, by its ID. * 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 e9ff2c2c2..c4e7735c9 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 @@ -77,6 +77,28 @@ constructor( }) } + /** + * Adds a new event or updates an existing event locally in the ViewModel's state. + * + * @param event The event to add or update. + */ + fun addEditEventLocally(event: Event) { + val existingEventIndex = _events.value.indexOfFirst { it.uid == event.uid } + + if (existingEventIndex != -1) { + val updatedEvents = _events.value.toMutableList() + updatedEvents[existingEventIndex] = event + _events.value = updatedEvents + } else { + _events.value = _events.value + event + } + + // if the selected event matches the updated event, refresh the selection + if (_selectedEvent.value?.uid == event.uid) { + _selectedEvent.value = event + } + } + /** * Updates the selected event in the ViewModel. * @@ -163,6 +185,45 @@ constructor( _events.value += event } + /** + * Adds an image to the specified event. This method uploads the image to the storage and updates + * the event's image URL once the upload is successful. + * + * This method helps in associating an image with an event, allowing the event to display a visual + * representation. + * + * @param inputStream The input stream of the image to upload. This is typically the raw data of + * the image selected by the user. + * @param event The event to which the image will be added. + * @param onSuccess A callback that is triggered if the image upload and event update are + * successful. It passes the updated event as a parameter. + * @param onFailure A callback that is triggered if the image upload or event update fails. It + * passes the error that occurred as a parameter. + */ + fun addImageToEvent( + inputStream: InputStream, + event: Event, + onSuccess: (Event) -> Unit, + onFailure: (Exception) -> Unit + ) { + try { + imageRepository.uploadImage( + inputStream, + "${StoragePathsStrings.EVENT_IMAGES}${event.uid}", + { uri -> + event.image = uri + onSuccess(event) + }, + { error -> + Log.e("ImageRepository", "Failed to upload image: $error") + onFailure(error) + }) + } catch (e: Exception) { + Log.e("addImageToEvent", "An unexpected error occurred: $e") + onFailure(e) + } + } + /** * Update an existing event in the repository with a new image. It uploads the event image first, * then updates the event. @@ -180,7 +241,7 @@ constructor( ) { imageRepository.uploadImage( inputStream, - // no need to delete the old image as it will be replaced by the new one + // No need to delete the old image as it will be replaced by the new one StoragePathsStrings.EVENT_IMAGES + event.uid, { uri -> event.image = uri @@ -196,13 +257,18 @@ constructor( isNewAssociation = false, it, {}, - { e -> Log.e("EventViewModel", "An error occurred while loading associations: $e") }) + { e -> Log.e("EventViewModel", "An error occurred while saving associations: $e") }) it.events.requestAll() } }) - _events.value = _events.value.filter { it.uid != event.uid } // Remove the outdated event - _events.value += event + // Update events list with the new event + _events.value = _events.value.map { if (it.uid == event.uid) event else it } + + // Update selected event if the updated event matches the current selected one + if (_selectedEvent.value?.uid == event.uid) { + _selectedEvent.value = event + } } /** diff --git a/app/src/main/java/com/android/unio/model/firestore/FirestoreReferenceList.kt b/app/src/main/java/com/android/unio/model/firestore/FirestoreReferenceList.kt index 02b6cee12..33a6613af 100644 --- a/app/src/main/java/com/android/unio/model/firestore/FirestoreReferenceList.kt +++ b/app/src/main/java/com/android/unio/model/firestore/FirestoreReferenceList.kt @@ -60,6 +60,22 @@ class FirestoreReferenceList( _uids.add(uid) } + /** + * Updates an element in the list. If the element is not already present, it is added. + * + * @param element The element to update. + */ + override fun update(element: T) { + val index = _list.value.indexOfFirst { it.uid == element.uid } + if (index != -1) { + // Element exists; replace it + _list.value = _list.value.toMutableList().apply { this[index] = element } + } else { + // Element does not exist; add it + add(element) + } + } + /** * Adds an element to the list. * diff --git a/app/src/main/java/com/android/unio/model/firestore/ReferenceList.kt b/app/src/main/java/com/android/unio/model/firestore/ReferenceList.kt index dd0f4ef22..e9e3124c8 100644 --- a/app/src/main/java/com/android/unio/model/firestore/ReferenceList.kt +++ b/app/src/main/java/com/android/unio/model/firestore/ReferenceList.kt @@ -12,6 +12,8 @@ interface ReferenceList { fun addAll(uids: List) + fun update(element: T) + fun remove(uid: String) fun requestAll(onSuccess: () -> Unit = {}, lazy: Boolean = false) diff --git a/app/src/main/java/com/android/unio/model/firestore/transform/Hydration.kt b/app/src/main/java/com/android/unio/model/firestore/transform/Hydration.kt index 17e318d24..8a23fbf9d 100644 --- a/app/src/main/java/com/android/unio/model/firestore/transform/Hydration.kt +++ b/app/src/main/java/com/android/unio/model/firestore/transform/Hydration.kt @@ -61,7 +61,7 @@ fun AssociationRepositoryFirestore.Companion.hydrate(data: Map?): A val role = roles.firstOrNull { it.uid == roleUid } ?: Role.GUEST // Return a Member containing the ReferenceElement and the associated Role - Member(user = userReference, role = role) + Member(user = userReference, roleUid = role.uid) } return Association( diff --git a/app/src/main/java/com/android/unio/model/firestore/transform/Serialization.kt b/app/src/main/java/com/android/unio/model/firestore/transform/Serialization.kt index 70c9d1ffa..7da69ee21 100644 --- a/app/src/main/java/com/android/unio/model/firestore/transform/Serialization.kt +++ b/app/src/main/java/com/android/unio/model/firestore/transform/Serialization.kt @@ -44,7 +44,7 @@ fun AssociationRepositoryFirestore.Companion.serialize(association: Association) * @return Map of user UIDs to role UIDs. */ fun mapUsersToRoles(members: List): Map { - return members.associate { member -> member.user.uid to member.role.uid } + return members.associate { member -> member.user.uid to member.roleUid } } /** diff --git a/app/src/main/java/com/android/unio/model/functions/CloudFunctions.kt b/app/src/main/java/com/android/unio/model/functions/CloudFunctions.kt new file mode 100644 index 000000000..42a72ce2b --- /dev/null +++ b/app/src/main/java/com/android/unio/model/functions/CloudFunctions.kt @@ -0,0 +1,200 @@ +package com.android.unio.model.functions + +import com.android.unio.model.association.Role +import com.android.unio.model.event.Event +import com.android.unio.model.event.EventRepositoryFirestore +import com.android.unio.model.firestore.transform.serialize +import com.google.firebase.Timestamp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.functions.ktx.functions +import com.google.firebase.ktx.Firebase +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +val firebaseFunctions: FirebaseFunctions by lazy { Firebase.functions } + +/** + * Retrieves the current user's token ID asynchronously. + * + * This function checks if the current user is signed in, and if so, retrieves their Firebase token + * ID. If the user is not signed in, or if there is an issue fetching the token, the `onError` + * callback is called. Otherwise, the `onSuccess` callback is invoked with the token ID. + * + * @param onSuccess A callback function that is called when the token ID is successfully retrieved. + * @param onError A callback function that is called if an error occurs while retrieving the token + * ID. + * @throws Exception If the user is not signed in or token retrieval fails. + */ +private fun giveCurrentUserTokenID(onSuccess: (String) -> Unit, onError: (Exception) -> Unit) { + val currentUser = FirebaseAuth.getInstance().currentUser + if (currentUser == null) { + onError(IllegalStateException("User is not signed in.")) + return + } + + currentUser.getIdToken(true).addOnCompleteListener { task -> + if (task.isSuccessful) { + val tokenId = task.result?.token + if (tokenId != null) { + onSuccess(tokenId) + } else { + onError(IllegalStateException("Token is null.")) + } + } else { + onError(task.exception ?: Exception("Failed to retrieve token ID.")) + } + } +} + +/** + * Converts a Firebase [Timestamp] object to a formatted string in ISO 8601 format. + * + * This function takes a [Timestamp] object and converts it to a string formatted as + * "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'". It ensures the timestamp is in UTC time zone. + * + * @param timestamp The Firebase [Timestamp] to be converted. + * @return A string representation of the timestamp in ISO 8601 format. + */ +fun convertTimestampToString(timestamp: Timestamp): String { + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) + format.timeZone = TimeZone.getTimeZone("UTC") // Make sure it's in UTC + return format.format(timestamp.toDate()) // Convert Timestamp to String (ISO 8601 format) +} + +/** + * Adds or edits an event by calling a Firebase Cloud Function to save the event. + * + * This function uploads event details to a Firebase Cloud Function, including the event information + * and associated data. Depending on whether it is a new event or an update, the appropriate action + * is taken. It retrieves the current user's token ID and sends it to the Cloud Function, along with + * the event data. + * + * @param newEvent The event object to be added or updated. + * @param associationUId The unique identifier of the association to which the event belongs. + * @param onSuccess A callback function that is called when the event is successfully added or + * updated. + * @param onError A callback function that is called if an error occurs during the process. + * @param isNewEvent A boolean value indicating whether the event is new (true) or being edited + * (false). + */ +fun addEditEventCloudFunction( + newEvent: Event, + associationUId: String, + onSuccess: (String) -> Unit, + onError: (Exception) -> Unit, + isNewEvent: Boolean +) { + try { + val serializedEvent = EventRepositoryFirestore.Companion.serialize(newEvent).toMutableMap() + + // Overwrite the startDate and endDate with the converted timestamp strings + serializedEvent[Event::startDate.name] = convertTimestampToString(newEvent.startDate) + serializedEvent[Event::endDate.name] = convertTimestampToString(newEvent.endDate) + + giveCurrentUserTokenID( + onSuccess = { tokenId -> + firebaseFunctions + .getHttpsCallable("saveEvent") + .call( + hashMapOf( + "tokenId" to tokenId, + "event" to serializedEvent, + "isNewEvent" to isNewEvent, + "associationUid" to associationUId)) + .addOnSuccessListener { result -> + val responseData = result.data as? String + if (responseData != null) { + onSuccess(responseData) + } else { + onError(IllegalStateException("Unexpected response format from Cloud Function.")) + } + } + .addOnFailureListener { error -> onError(error) } + }, + onError = { error -> onError(error) }) + } catch (e: Exception) { + onError(e) + } +} + +/** + * Serializes a Role object and associated metadata for Firebase Cloud Function calls. + * + * This function converts a Role object into a map that can be sent to Firebase Cloud Functions, + * including the necessary fields and associated metadata. + * + * @param tokenId The token ID of the current user. + * @param newRole The role object to be serialized. + * @param associationUId The unique identifier of the association to which the role belongs. + * @param isNewRole A boolean value indicating whether the role is new (true) or being edited + * (false). + * @return A map containing the serialized role data and associated metadata. + */ +private fun serializeRoleData( + tokenId: String, + newRole: Role, + associationUId: String, + isNewRole: Boolean +): Map { + return mapOf( + "tokenId" to tokenId, + "role" to + mapOf( + "displayName" to newRole.displayName, + "permissions" to + newRole.permissions.getGrantedPermissions().toList().map { permission -> + permission.stringName + }, + "color" to newRole.color.toInt(), + "uid" to newRole.uid), + "isNewRole" to isNewRole, + "associationUid" to associationUId) +} + +/** + * Adds or edits a role by calling a Firebase Cloud Function to save the role. + * + * This function uploads role details to a Firebase Cloud Function, including role-specific + * information and permissions. It retrieves the current user's token ID and sends it to the Cloud + * Function, along with the role data. + * + * @param newRole The role object to be added or updated. + * @param associationUId The unique identifier of the association to which the role belongs. + * @param onSuccess A callback function that is called when the role is successfully added or + * updated. + * @param onError A callback function that is called if an error occurs during the process. + * @param isNewRole A boolean value indicating whether the role is new (true) or being edited + * (false). + */ +fun addEditRoleCloudFunction( + newRole: Role, + associationUId: String, + onSuccess: (String) -> Unit, + onError: (Exception) -> Unit, + isNewRole: Boolean +) { + try { + giveCurrentUserTokenID( + onSuccess = { tokenId -> + val requestData = serializeRoleData(tokenId, newRole, associationUId, isNewRole) + + firebaseFunctions + .getHttpsCallable("saveRole") + .call(requestData) + .addOnSuccessListener { result -> + val responseData = result.data as? String + if (responseData != null) { + onSuccess(responseData) + } else { + onError(IllegalStateException("Unexpected response format from Cloud Function.")) + } + } + .addOnFailureListener { error -> onError(error) } + }, + onError = { error -> onError(error) }) + } catch (e: Exception) { + onError(e) + } +} diff --git a/app/src/main/java/com/android/unio/model/search/SearchRepository.kt b/app/src/main/java/com/android/unio/model/search/SearchRepository.kt index b2f11a753..52192a922 100644 --- a/app/src/main/java/com/android/unio/model/search/SearchRepository.kt +++ b/app/src/main/java/com/android/unio/model/search/SearchRepository.kt @@ -11,6 +11,9 @@ import androidx.appsearch.localstorage.LocalStorage import com.android.unio.model.association.Association import com.android.unio.model.association.AssociationDocument import com.android.unio.model.association.AssociationRepository +import com.android.unio.model.association.AssociationViewModel +import com.android.unio.model.association.Member +import com.android.unio.model.association.MemberDocument import com.android.unio.model.association.toAssociationDocument import com.android.unio.model.authentication.registerAuthStateListener import com.android.unio.model.event.Event @@ -76,6 +79,32 @@ constructor( } } + /** + * Refreshes the AppSearch database with the associations currently in the AssociationViewModel. + * + * @param associationViewModel The AssociationViewModel containing the current associations. + */ + suspend fun refreshAssociations(associationViewModel: AssociationViewModel) { + withContext(Dispatchers.IO) { + try { + // Get the current associations from the view model + val associations = associationViewModel.associations.value + val associationDocuments = associations.map { it.toAssociationDocument() } + + // Clear the existing associations in AppSearch + session?.removeAsync( + RemoveByDocumentIdRequest.Builder("unio") + .addIds(associationDocuments.map { it.uid }) + .build()) + + // Add the fresh set of associations to AppSearch + session?.putAsync(PutDocumentsRequest.Builder().addDocuments(associationDocuments).build()) + } catch (e: Exception) { + Log.e("SearchRepository", "Failed to refresh associations in AppSearch", e) + } + } + } + /** * Calls the [AssociationRepository] to fetch all associations and adds them to the search * database. If the call fails, logs the exception. @@ -128,6 +157,30 @@ constructor( } } + /** + * Extracts and adds the members from the given list of [Association] objects to the search + * database. Each member is associated with their respective association. + * + * @param associations The list of [Association] objects to extract members from. + */ + private fun addMembersFromAssociations(associations: List) { + val memberDocuments = + associations.flatMap { association -> + association.members.map { member -> + MemberDocument( + uid = member.uid, + userUid = member.user.uid, + role = (association.roles.find { it.uid == member.uid }?.displayName ?: ""), + associationUid = association.uid) + } + } + try { + session?.putAsync(PutDocumentsRequest.Builder().addDocuments(memberDocuments).build()) + } catch (e: Exception) { + Log.e("SearchRepository", "failed to add members to search database", e) + } + } + /** * Removes the association or event with the given uid from the search database. * @@ -156,10 +209,8 @@ constructor( .setRankingStrategy(SearchSpec.RANKING_STRATEGY_NONE) .build() val result = session?.search(query, searchSpec) ?: return@withContext emptyList() - val associations = mutableListOf() val page = result.nextPageAsync.await() - page.forEach { val doc = it.getDocument(AssociationDocument::class.java) associations.add(associationDocumentToAssociation(doc)) @@ -195,6 +246,35 @@ constructor( } } + /** + * Searches the search database for members that match the given query. + * + * @param query The search query to look for in the database. + * @return A list of [Member] objects that match the search query. + */ + suspend fun searchMembers(query: String): List { + return withContext(Dispatchers.IO) { + val searchSpec = + SearchSpec.Builder() + .setSnippetCount(10) + .addFilterDocumentClasses(MemberDocument::class.java) + .setRankingStrategy(SearchSpec.RANKING_STRATEGY_NONE) + .build() + + val result = session?.search(query, searchSpec) ?: return@withContext emptyList() + + val members = mutableListOf() + val page = result.nextPageAsync.await() + + page.forEach { + val doc = it.getDocument(MemberDocument::class.java) + members.add(memberDocumentToMember(doc)) + } + + return@withContext members + } + } + /** * Converts the given [AssociationDocument] to an [Association]. * @@ -248,6 +328,26 @@ constructor( } } + /** + * Converts the given [MemberDocument] to a [Member] object. This method fetches the full member + * details using the [AssociationRepository]. + * + * @param memberDocument The [MemberDocument] to convert. + * @return The corresponding [Member] object. + */ + private suspend fun memberDocumentToMember(memberDocument: MemberDocument): Member { + return suspendCoroutine { continuation -> + associationRepository.getAssociationWithId( + id = memberDocument.associationUid, + onSuccess = { association -> + val member = association.members.firstOrNull { it.user.uid == memberDocument.userUid } + member?.let { continuation.resume(it) } + ?: continuation.resumeWithException(IllegalArgumentException("Member not found")) + }, + onFailure = { exception -> continuation.resumeWithException(exception) }) + } + } + /** Closes the session and releases the resources. */ fun closeSession() { session?.close() diff --git a/app/src/main/java/com/android/unio/model/search/SearchViewModel.kt b/app/src/main/java/com/android/unio/model/search/SearchViewModel.kt index 5c108ce3e..78e7e69b7 100644 --- a/app/src/main/java/com/android/unio/model/search/SearchViewModel.kt +++ b/app/src/main/java/com/android/unio/model/search/SearchViewModel.kt @@ -3,6 +3,7 @@ package com.android.unio.model.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.unio.model.association.Association +import com.android.unio.model.association.Member import com.android.unio.model.event.Event import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -26,6 +27,9 @@ class SearchViewModel @Inject constructor(private val repository: SearchReposito private val _events = MutableStateFlow>(emptyList()) val events: StateFlow> = _events.asStateFlow() + private val _members = MutableStateFlow>(emptyList()) + val members: StateFlow> = _members.asStateFlow() + val status = MutableStateFlow(Status.IDLE) // Used to debounce the search query @@ -50,7 +54,8 @@ class SearchViewModel @Inject constructor(private val repository: SearchReposito */ enum class SearchType { ASSOCIATION, - EVENT + EVENT, + MEMBER } /** Initializes the ViewModel by creating the search database and connecting it to the session. */ init { @@ -72,6 +77,21 @@ class SearchViewModel @Inject constructor(private val repository: SearchReposito } } + /** + * Searches the members in the search database using the given query and updates the internal + * [MutableStateFlow] with the results. + * + * @param query The query to search for. + */ + fun searchMembers(query: String) { + viewModelScope.launch { + status.value = Status.LOADING + val results = repository.searchMembers(query) + _members.value = results + status.value = Status.SUCCESS + } + } + /** * Debounces the search query to avoid making too many requests in a short period of time. * @@ -84,6 +104,7 @@ class SearchViewModel @Inject constructor(private val repository: SearchReposito when (searchType) { SearchType.EVENT -> clearEvents() SearchType.ASSOCIATION -> clearAssociations() + SearchType.MEMBER -> clearMembers() } } else { searchJob = @@ -92,6 +113,7 @@ class SearchViewModel @Inject constructor(private val repository: SearchReposito when (searchType) { SearchType.EVENT -> searchEvents(query) SearchType.ASSOCIATION -> searchAssociations(query) + SearchType.MEMBER -> searchMembers(query) } } } @@ -125,6 +147,16 @@ class SearchViewModel @Inject constructor(private val repository: SearchReposito } } + /** Clears the list of members and sets the search status to [Status.IDLE]. */ + private fun clearMembers() { + _members.value = emptyList() + status.value = Status.IDLE + } + + /** + * Called when the ViewModel is cleared. This is typically used to release any resources or + * perform cleanup tasks. In this case, it closes the search session from the [repository]. + */ public override fun onCleared() { super.onCleared() repository.closeSession() diff --git a/app/src/main/java/com/android/unio/model/strings/test_tags/association/AssociationTestTags.kt b/app/src/main/java/com/android/unio/model/strings/test_tags/association/AssociationTestTags.kt index 8b89b3f7c..b82b34875 100644 --- a/app/src/main/java/com/android/unio/model/strings/test_tags/association/AssociationTestTags.kt +++ b/app/src/main/java/com/android/unio/model/strings/test_tags/association/AssociationTestTags.kt @@ -37,6 +37,25 @@ object AssociationProfileTestTags { const val HEADER_FOLLOWERS = "associationHeaderFollowers" const val HEADER_MEMBERS = "associationHeaderMembers" const val FOLLOW_BUTTON = "associationFollowButton" + + // PAGE + const val OVERVIEW_PAGE = "associationOverviewPage" + const val ACTIONS_PAGE = "associationActionsPage" + + const val YOUR_ROLE_TEXT = "associationProfileYourRoleText" +} + +object AssociationProfileActionsTestTags { + const val SCREEN = "associationProfileActionsScreen" + const val EVENT_TITLE = "associationEventActionsTitle" + const val SMALL_EVENT_TITLE = "associationSmallEventActionsTitle" + const val ADD_EVENT_BUTTON = "associationActionsAddEventButton" + const val BROADCAST_ICON_BUTTON = "associationActionsBroadcastButton" + const val EDIT_BUTTON = "associationActionsEditButton" + const val CREATE_ROLE = "associationActionsCreateRole" + const val CREATE_ROLE_DISPLAY_NAME = "associationActionsCreateRoleDisplayName" + const val EDIT_ROLE = "associationActionsEditRole" + const val DELETE_ROLE = "associationActionsDeleteRole" } object SaveAssociationTestTags { diff --git a/app/src/main/java/com/android/unio/model/strings/test_tags/event/EventTestTags.kt b/app/src/main/java/com/android/unio/model/strings/test_tags/event/EventTestTags.kt index 475c0646c..1909f4542 100644 --- a/app/src/main/java/com/android/unio/model/strings/test_tags/event/EventTestTags.kt +++ b/app/src/main/java/com/android/unio/model/strings/test_tags/event/EventTestTags.kt @@ -94,6 +94,8 @@ object EventDetailsTestTags { const val SHARE_BUTTON = "eventShareButton" const val DETAILS_PAGE = "eventDetailsPage" const val DETAILS_IMAGE = "eventDetailsImage" + const val DETAILS_PAGE_HP = "eventDetailsPageHP" + const val DETAILS_IMAGE_HP = "eventDetailsImageHP" const val DETAILS_INFORMATION_CARD = "eventDetailsInformationCard" const val TITLE = "eventTitle" const val ORGANIZING_ASSOCIATION = "eventOrganisingAssociation" diff --git a/app/src/main/java/com/android/unio/model/user/User.kt b/app/src/main/java/com/android/unio/model/user/User.kt index 437900180..7c3ec26a7 100644 --- a/app/src/main/java/com/android/unio/model/user/User.kt +++ b/app/src/main/java/com/android/unio/model/user/User.kt @@ -192,6 +192,10 @@ fun getPlaceHolderText(social: Social): String { } fun getUserRoleInAssociation(association: Association, userUid: String): Role? { - val member = association.members.find { it.user.uid == userUid } - return member?.role + + val roleOfMember = + association.roles.find { + it.uid == association.members.find { it.user.uid == userUid }?.roleUid + } + return roleOfMember } 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 f272a80cb..e3fbbb0cc 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 @@ -12,26 +12,40 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -40,9 +54,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -50,6 +61,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -57,9 +69,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.android.unio.R @@ -67,29 +82,30 @@ import com.android.unio.model.association.Association import com.android.unio.model.association.AssociationViewModel import com.android.unio.model.association.Member import com.android.unio.model.association.PermissionType +import com.android.unio.model.association.Permissions +import com.android.unio.model.association.Role import com.android.unio.model.event.Event import com.android.unio.model.event.EventViewModel +import com.android.unio.model.functions.addEditRoleCloudFunction import com.android.unio.model.notification.NotificationType +import com.android.unio.model.search.SearchViewModel +import com.android.unio.model.strings.test_tags.association.AssociationProfileActionsTestTags import com.android.unio.model.strings.test_tags.association.AssociationProfileTestTags import com.android.unio.model.user.User import com.android.unio.model.user.UserViewModel import com.android.unio.model.utils.NetworkUtils import com.android.unio.ui.components.NotificationSender import com.android.unio.ui.components.RoleBadge +import com.android.unio.ui.components.SearchPagerSection import com.android.unio.ui.event.EventCard import com.android.unio.ui.image.AsyncImageWrapper import com.android.unio.ui.navigation.NavigationAction import com.android.unio.ui.navigation.Screen +import com.android.unio.ui.navigation.SmoothTopBarNavigationMenu import com.android.unio.ui.theme.AppTypography import com.android.unio.ui.utils.ToastUtils -import kotlinx.coroutines.CoroutineScope - -// These variable are only here for testing purpose. They should be deleted when the screen is -// linked to the backend -private const val DEBUG_MESSAGE = " Not implemented yet" - -private var testSnackbar: SnackbarHostState? = null -private var scope: CoroutineScope? = null +import com.github.skydoves.colorpicker.compose.HsvColorPicker +import com.github.skydoves.colorpicker.compose.rememberColorPickerController /** * Composable element that contain the association profile screen. It display the association. @@ -103,6 +119,7 @@ private var scope: CoroutineScope? = null fun AssociationProfileScreen( navigationAction: NavigationAction, associationViewModel: AssociationViewModel, + searchViewModel: SearchViewModel, userViewModel: UserViewModel, eventViewModel: EventViewModel ) { @@ -120,6 +137,7 @@ fun AssociationProfileScreen( userViewModel = userViewModel, eventViewModel = eventViewModel, associationViewModel = associationViewModel, + searchViewModel = searchViewModel, onEdit = { associationViewModel.selectAssociation(association!!.uid) navigationAction.navigateTo(Screen.SAVE_ASSOCIATION) @@ -145,86 +163,184 @@ fun AssociationProfileScaffold( userViewModel: UserViewModel, eventViewModel: EventViewModel, associationViewModel: AssociationViewModel, + searchViewModel: SearchViewModel, onEdit: () -> Unit ) { - val associationState by associationViewModel.selectedAssociation.collectAsState() - val association = associationState!! - - var showSheet by remember { mutableStateOf(false) } + val associationCollect by associationViewModel.selectedAssociation.collectAsState() val refreshState by associationViewModel.refreshState val pullRefreshState = rememberPullRefreshState( - refreshing = refreshState, onRefresh = { associationViewModel.refreshAssociation() }) + refreshing = refreshState, + onRefresh = { + associationViewModel.refreshAssociation() + eventViewModel.loadEvents() + }) + + var showNotificationDialog by remember { mutableStateOf(false) } + var showSheet by remember { mutableStateOf(false) } val context = LocalContext.current - testSnackbar = remember { SnackbarHostState() } - scope = rememberCoroutineScope() - Scaffold( - snackbarHost = { - SnackbarHost( - hostState = testSnackbar!!, - modifier = Modifier.testTag(AssociationProfileTestTags.SNACKBAR_HOST), - snackbar = { - Snackbar { - TextButton( - onClick = { testSnackbar!!.currentSnackbarData?.dismiss() }, + associationCollect?.let { association -> + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = association.name, + modifier = Modifier.testTag(AssociationProfileTestTags.TITLE)) + }, + navigationIcon = { + IconButton( + onClick = { navigationAction.goBack() }, + modifier = Modifier.testTag(AssociationProfileTestTags.GO_BACK_BUTTON)) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = context.getString(R.string.association_go_back)) + } + }, + actions = { + Row { + IconButton( + modifier = Modifier.testTag(AssociationProfileTestTags.MORE_BUTTON), + onClick = { showSheet = true }) { + Icon( + Icons.Outlined.MoreVert, + contentDescription = context.getString(R.string.association_more)) + } + } + }) + }, + content = { padding -> + Box(modifier = Modifier.padding(0.dp).fillMaxSize()) { + val user by userViewModel.user.collectAsState() + val userRole = + association.roles.find { + it.uid == association.members.find { it.uid == user!!.uid }?.roleUid + } + + val userPermissions = userRole?.permissions + + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + if (userPermissions?.hasAnyPermission() == true) { + val userRoleColor = Color(userRole.color) + + Box( modifier = - Modifier.testTag(AssociationProfileTestTags.SNACKBAR_ACTION_BUTTON)) { - Text(text = DEBUG_MESSAGE) + Modifier.fillMaxWidth() + .background(userRoleColor) + .height(50.dp) + .align(Alignment.TopCenter)) { + Text( + context.getString(R.string.association_profile_scaffold_role_is) + + " " + + userRole.displayName, + color = Color.White, + modifier = + Modifier.align(Alignment.Center) + .testTag(AssociationProfileTestTags.YOUR_ROLE_TEXT)) } - } - }) - }, - topBar = { - TopAppBar( - title = { - Text( - text = association.name, - modifier = Modifier.testTag(AssociationProfileTestTags.TITLE)) - }, - navigationIcon = { - IconButton( - onClick = { navigationAction.goBack() }, - modifier = Modifier.testTag(AssociationProfileTestTags.GO_BACK_BUTTON)) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = context.getString(R.string.association_go_back)) + + Box(modifier = Modifier.fillMaxSize().padding(top = 50.dp)) { + Row(modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp)) { + Box(modifier = Modifier.width(2.dp).fillMaxHeight().background(userRoleColor)) + + // Main content (Conditional based on permission) + if (userPermissions.hasPermission(PermissionType.BETTER_OVERVIEW) && + !(userPermissions.hasPermission(PermissionType.FULL_RIGHTS)) && + userPermissions.getGrantedPermissions().size == 1) { + // Default content without HorizontalPager + Box( + modifier = + Modifier.weight(1f) + .padding(horizontal = 8.dp) + .pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState())) { + AssociationProfileContent( + navigationAction = navigationAction, + userViewModel = userViewModel, + eventViewModel = eventViewModel, + associationViewModel = associationViewModel) + } + } else { + // Main content with HorizontalPager + Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) { + val nbOfTabs = 2 + val pagerState = rememberPagerState(initialPage = 0) { nbOfTabs } + + // Tab Menu + val tabList = + listOf( + context.getString(R.string.association_profile_scaffold_overview), + context.getString(R.string.association_profile_scaffold_actions)) + SmoothTopBarNavigationMenu( + tabList, + pagerState, + listOf( + AssociationProfileTestTags.OVERVIEW_PAGE, + AssociationProfileTestTags.ACTIONS_PAGE)) + + // Pager Content + HorizontalPager( + userScrollEnabled = false, + state = pagerState, + modifier = Modifier.fillMaxSize()) { page -> + when (page) { + 0 -> + Box( + modifier = + Modifier.pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState())) { + AssociationProfileContent( + navigationAction = navigationAction, + userViewModel = userViewModel, + eventViewModel = eventViewModel, + associationViewModel = associationViewModel) + } + 1 -> + Box( + modifier = + Modifier.pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState())) { + AssociationProfileActionsContent( + navigationAction = navigationAction, + userViewModel = userViewModel, + eventViewModel = eventViewModel, + associationViewModel = associationViewModel, + searchViewModel = searchViewModel) + } + } + } + } + } + + Box(modifier = Modifier.width(2.dp).fillMaxHeight().background(userRoleColor)) } - }, - actions = { - Row { - IconButton( - modifier = Modifier.testTag(AssociationProfileTestTags.MORE_BUTTON), - onClick = { showSheet = true }) { - Icon( - Icons.Outlined.MoreVert, - contentDescription = context.getString(R.string.association_more)) + } + } else { + // Default content without permissions + Box( + modifier = + Modifier.pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState())) { + AssociationProfileContent( + navigationAction = navigationAction, + userViewModel = userViewModel, + eventViewModel = eventViewModel, + associationViewModel = associationViewModel) } } - }) - }, - content = { padding -> - Box( - modifier = - Modifier.padding(padding) - .pullRefresh(pullRefreshState) - .fillMaxHeight() - .verticalScroll(rememberScrollState())) { - AssociationProfileContent( - navigationAction, userViewModel, eventViewModel, associationViewModel) } - }) - - var showNotificationDialog by remember { mutableStateOf(false) } - - NotificationSender( - context.getString(R.string.association_broadcast_message), - NotificationType.ASSOCIATION_FOLLOWERS, - association.uid, - { mapOf("title" to association.name, "body" to it) }, - showNotificationDialog, - { showNotificationDialog = false }) + } + }) + NotificationSender( + context.getString(R.string.association_broadcast_message), + NotificationType.ASSOCIATION_FOLLOWERS, + association.uid, + { mapOf("title" to association.name, "body" to it) }, + showNotificationDialog, + { showNotificationDialog = false }) + } AssociationProfileBottomSheet( showSheet, @@ -303,7 +419,7 @@ fun AssociationProfileBottomSheet( * @param associationViewModel [AssociationViewModel] : The association view model */ @Composable -private fun AssociationProfileContent( +fun AssociationProfileContent( navigationAction: NavigationAction, userViewModel: UserViewModel, eventViewModel: EventViewModel, @@ -342,7 +458,6 @@ private fun AssociationProfileContent( navigationAction.navigateTo(Screen.SOMEONE_ELSE_PROFILE) } - // Add spacedBy to the horizontalArrangement Column( modifier = Modifier.testTag(AssociationProfileTestTags.SCREEN).fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { @@ -353,6 +468,79 @@ private fun AssociationProfileContent( } } +/** + * Composable element that contain the actions of the given association. It call all elements that + * should be displayed on the screen, such as the header, the description, the events... + * + * @param navigationAction [NavigationAction] : The navigation actions of the screen + * @param userViewModel [UserViewModel] : The user view model + * @param eventViewModel [EventViewModel] : The event view model + * @param associationViewModel [AssociationViewModel] : The association view model + */ +@Composable +private fun AssociationProfileActionsContent( + navigationAction: NavigationAction, + userViewModel: UserViewModel, + eventViewModel: EventViewModel, + associationViewModel: AssociationViewModel, + searchViewModel: SearchViewModel +) { + val context = LocalContext.current + val association by associationViewModel.selectedAssociation.collectAsState() + val user by userViewModel.user.collectAsState() + + if (association == null || user == null) { + Log.e("AssociationProfileContent", "Association or user not found.") + return + } + + var isFollowed by remember { + mutableStateOf(user!!.followedAssociations.contains(association!!.uid)) + } + var enableButton by remember { mutableStateOf(true) } + val isConnected = NetworkUtils.checkInternetConnection(context) + + val onFollow = { + if (isConnected) { + enableButton = false + associationViewModel.updateFollow(association!!, user!!, isFollowed) { + userViewModel.refreshUser() + enableButton = true + } + isFollowed = !isFollowed + } else { + ToastUtils.showToast(context, context.getString(R.string.no_internet_connection)) + } + } + + val onMemberClick = { member: User -> + userViewModel.setSomeoneElseUser(member) + navigationAction.navigateTo(Screen.SOMEONE_ELSE_PROFILE) + } + + Column( + modifier = + Modifier.testTag(AssociationProfileActionsTestTags.SCREEN).fillMaxWidth().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp)) { + AssociationActionsHeader( + association!!, + isFollowed, + enableButton, + onFollow, + onClickSaveButton = { + associationViewModel.selectAssociation(association!!.uid) + navigationAction.navigateTo(Screen.SAVE_ASSOCIATION) + }) + AssociationActionsEvents( + navigationAction, + association!!, + userViewModel, + eventViewModel, + searchViewModel = searchViewModel) + AssociationActionsMembers(associationViewModel, onMemberClick) + } +} + /** * Component that displays the users that are in the association that can be contacted. It displays * the title of the section and then displays the different users in the association. @@ -381,12 +569,12 @@ private fun AssociationMembers( horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(16.dp)) { members.forEach { member -> - val user = associationViewModel.getUserFromMember(member).collectAsState() + val user by associationViewModel.getUserFromMember(member).collectAsState() Column( modifier = Modifier.background( MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(8.dp)) - .clickable { user.value?.let { onMemberClick(it) } } + .clickable { user?.let { onMemberClick(it) } } .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { @@ -395,7 +583,7 @@ private fun AssociationMembers( Modifier.clip(CircleShape) .size(75.dp) .background(MaterialTheme.colorScheme.surfaceDim)) { - user.value?.profilePicture?.toUri()?.let { + user?.profilePicture?.toUri()?.let { AsyncImageWrapper( imageUri = it, contentDescription = @@ -405,14 +593,22 @@ private fun AssociationMembers( contentScale = ContentScale.Crop) } } - user.value?.firstName?.let { + user?.firstName?.let { val firstName = it - user.value?.lastName?.let { + user?.lastName?.let { val lastName = it Text("$firstName $lastName") // Role Badge - RoleBadge(member.role) + val association = associationViewModel.selectedAssociation.value + val userRole = + association?.roles?.find { + it.uid == association.members.find { it.uid == user?.uid }?.roleUid + } + + if (userRole != null) { + RoleBadge(userRole) + } } } } @@ -420,6 +616,380 @@ private fun AssociationMembers( } } +/** + * Displays a list of members of an association and allows searching for members using a search bar. + * When a member is selected from the search, the view scrolls to the respective member's card. + * Additionally, it allows managing the association's roles. + * + * @param associationViewModel The ViewModel responsible for managing the association's data. + * @param userUid The unique identifier of the current user. + * @param onMemberClick A lambda function to be called when a member's card is clicked, passing the + * selected member's data. + * @param searchViewModel The ViewModel responsible for managing member search functionality. + */ +@Composable +private fun AssociationActionsMembers( + associationViewModel: AssociationViewModel, + onMemberClick: (User) -> Unit, +) { + val context = LocalContext.current + + val association by associationViewModel.selectedAssociation.collectAsState() + val members = association?.members + val pagerState = rememberPagerState(initialPage = 0) { members?.size ?: 0 } + + val cardContent: @Composable (Member) -> Unit = { member -> + val user by associationViewModel.getUserFromMember(member).collectAsState() + Box( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + contentAlignment = Alignment.Center) { + Column( + modifier = + Modifier.background( + MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(8.dp)) + .clickable { user?.let { onMemberClick(it) } } + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier.clip(CircleShape) + .size(75.dp) + .background(MaterialTheme.colorScheme.surfaceDim)) { + user?.profilePicture?.toUri()?.let { + AsyncImageWrapper( + imageUri = it, + contentDescription = + context.getString( + R.string.association_contact_member_profile_picture), + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop) + } + } + user?.firstName?.let { firstName -> + user?.lastName?.let { lastName -> + Text("$firstName $lastName") + + // Role Badge + val association = associationViewModel.selectedAssociation.value + val userRole = + association?.roles?.find { + it.uid == association.members.find { it.uid == user?.uid }?.roleUid + } + + if (userRole != null) { + RoleBadge(userRole) + } + } + } + } + } + } + + if (members != null) { + SearchPagerSection(items = members, cardContent = { cardContent(it) }, pagerState = pagerState) + + association?.let { + RolesManagementScreen(it.roles, associationViewModel = associationViewModel) + } + } +} + +/** + * Displays the list of roles for an association and allows creating, editing, and deleting roles. + * Each role is displayed as a clickable card, and users can perform actions like editing or + * deleting the role. A dialog is shown for creating or editing roles, and another dialog confirms + * deletion. + * + * @param roles The list of roles for the association. + * @param associationViewModel The ViewModel responsible for managing the association's data. + */ +@Composable +fun RolesManagementScreen(roles: List, associationViewModel: AssociationViewModel) { + var showCreateRoleDialog by remember { mutableStateOf(false) } + + val context = LocalContext.current + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Text(text = "Roles", style = MaterialTheme.typography.headlineMedium) + + Spacer(modifier = Modifier.height(16.dp)) + + roles.forEach { role -> RoleCard(role, associationViewModel) } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { showCreateRoleDialog = true }, + modifier = Modifier.testTag(AssociationProfileActionsTestTags.CREATE_ROLE)) { + Text(text = context.getString(R.string.association_profile_create_role)) + } + + // Show dialog for creating a new role + if (showCreateRoleDialog) { + SaveRoleDialog( + onDismiss = { showCreateRoleDialog = false }, + onCreateRole = { newRole -> showCreateRoleDialog = false }, + associationViewModel = associationViewModel) + } + } +} + +/** + * Displays the card for a specific role in the association, allowing users to edit or delete it. + * The card shows the role's display name, color, and granted permissions. Users can click the card + * to expand it and see more information. Editing and deleting a role triggers respective dialogs. + * + * @param role The role to be displayed. + * @param associationViewModel The ViewModel responsible for managing the association's data. + */ +@Composable +fun RoleCard(role: Role, associationViewModel: AssociationViewModel) { + var expanded by remember { mutableStateOf(false) } + var showEditDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + + val context = LocalContext.current + + Card( + modifier = + Modifier.fillMaxWidth().padding(vertical = 8.dp).clickable { expanded = !expanded }, + elevation = CardDefaults.cardElevation(4.dp)) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(24.dp).background(Color(role.color))) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = role.displayName, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f)) + + Row { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = + context.getString(R.string.association_profile_role_card_edit_role), + modifier = + Modifier.size(24.dp) + .clickable { showEditDialog = true } + .padding(4.dp) + .testTag(AssociationProfileActionsTestTags.EDIT_ROLE + role.displayName)) + + Icon( + imageVector = Icons.Default.Delete, + contentDescription = + context.getString(R.string.association_profile_role_card_delete_role), + modifier = + Modifier.size(24.dp) + .clickable { showDeleteDialog = true } + .padding(4.dp) + .testTag( + AssociationProfileActionsTestTags.DELETE_ROLE + role.displayName)) + } + } + + if (expanded) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Permissions:", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary) + + Spacer(modifier = Modifier.height(4.dp)) + + role.permissions.getGrantedPermissions().forEach { permission -> + Text( + text = "- ${permission.stringName}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp)) + } + } + } + } + + // Edit Role Dialog + if (showEditDialog) { + SaveRoleDialog( + onDismiss = { showEditDialog = false }, + onCreateRole = { updatedRole -> + associationViewModel.selectedAssociation.value?.let { association -> + associationViewModel.editRoleLocally(association.uid, updatedRole) + } + showEditDialog = false + }, + associationViewModel = associationViewModel, + initialRole = role) + } + + // Delete Role Confirmation Dialog + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + confirmButton = { + Button( + onClick = { + associationViewModel.selectedAssociation.value?.let { association -> + associationViewModel.deleteRoleLocally(association.uid, role) + } + showDeleteDialog = false + }) { + Text(context.getString(R.string.association_profile_role_card_delete)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(context.getString(R.string.association_profile_role_card_cancel)) + } + }, + title = { Text(context.getString(R.string.association_profile_role_card_delete_role)) }, + text = { + Text( + context.getString(R.string.association_profile_role_card_sure_delete_role) + + " '${role.displayName}'?") + }) + } +} + +/** + * Displays a dialog to create or edit a role within the association. If an existing role is passed, + * the dialog will allow editing the role's details, including the display name, color, and + * permissions. If no existing role is passed, the dialog will create a new role. Once the role is + * saved, it triggers the appropriate actions in the [associationViewModel]. + * + * @param onDismiss A lambda function to be called when the dialog is dismissed. + * @param onCreateRole A lambda function to be called after the role is created or updated, passing + * the role. + * @param associationViewModel The ViewModel responsible for managing the association's data. + * @param initialRole The initial role details to prefill the dialog fields (optional, null if + * creating a new role). + */ +@Composable +fun SaveRoleDialog( + onDismiss: () -> Unit, + onCreateRole: (Role) -> Unit, + associationViewModel: AssociationViewModel, + initialRole: Role? = null +) { + var displayName by remember { mutableStateOf(TextFieldValue(initialRole?.displayName ?: "")) } + val controller = rememberColorPickerController() + var selectedColor by remember { + mutableStateOf(Color(initialRole?.color ?: Color.White.toArgb().toLong())) + } + val selectedPermissions = remember { + mutableStateListOf().apply { + initialRole?.permissions?.getGrantedPermissions()?.let { addAll(it) } + } + } + val allPermissions = PermissionType.values() + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button( + onClick = { + val colorInt = selectedColor.toArgb().toLong() + val saveRole = + Role( + displayName = displayName.text, + permissions = + Permissions.PermissionsBuilder() + .addPermissions(selectedPermissions.toList()) + .build(), + color = colorInt, + uid = initialRole?.uid ?: displayName.text) + associationViewModel.selectedAssociation.value?.let { association -> + addEditRoleCloudFunction( + saveRole, + association.uid, + onSuccess = { associationViewModel.addRoleLocally(association.uid, saveRole) }, + onError = { e -> Log.e("ADD_ROLE", "ERROR: $e") }, + isNewRole = initialRole == null) + } + onCreateRole(saveRole) + }) { + Text( + if (initialRole != null) + context.getString(R.string.association_profile_save_role_dialog_save) + else context.getString(R.string.association_profile_save_role_dialog_create)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(context.getString(R.string.association_profile_save_role_dialog_cancel)) + } + }, + title = { + Text( + if (initialRole != null) + context.getString(R.string.association_profile_save_role_dialog_edit_role) + else context.getString(R.string.association_profile_save_role_dialog_create_role)) + }, + text = { + Column(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + Text( + text = + context.getString(R.string.association_profile_save_role_dialog_display_name), + style = MaterialTheme.typography.labelMedium) + BasicTextField( + value = displayName, + onValueChange = { displayName = it }, + modifier = + Modifier.fillMaxWidth() + .background(Color.LightGray) + .padding(8.dp) + .testTag(AssociationProfileActionsTestTags.CREATE_ROLE_DISPLAY_NAME)) + } + + Column(Modifier.fillMaxWidth().padding(vertical = 16.dp)) { + Text( + context.getString(R.string.association_profile_save_role_dialog_role_color), + style = MaterialTheme.typography.labelMedium) + HsvColorPicker( + modifier = Modifier.fillMaxWidth().height(200.dp).padding(10.dp), + controller = controller, + onColorChanged = { colorEnvelope -> selectedColor = colorEnvelope.color }, + initialColor = selectedColor) + } + + Text( + text = context.getString(R.string.association_profile_save_role_dialog_permissions), + style = MaterialTheme.typography.labelMedium) + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(allPermissions.size) { index -> + val permission = allPermissions[index] + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { + if (selectedPermissions.contains(permission)) { + selectedPermissions.remove(permission) + } else { + selectedPermissions.add(permission) + } + } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selectedPermissions.contains(permission), + onCheckedChange = { + if (it) { + selectedPermissions.add(permission) + } else { + selectedPermissions.remove(permission) + } + }) + Text(text = permission.stringName) + } + } + } + } + }) +} + /** * Component that display all the events of the association in a card format, like in the home * screen. @@ -437,25 +1007,9 @@ private fun AssociationEvents( eventViewModel: EventViewModel ) { val context = LocalContext.current - val isConnected = NetworkUtils.checkInternetConnection(context) - var isSeeMoreClicked by remember { mutableStateOf(false) } - val events by association.events.list.collectAsState() - val user by userViewModel.user.collectAsState() - - if (user == null) { - return - } - // Check if the user is a member of the association - val isMember = association.members.any { it.uid == user!!.uid } - - // Retrieve the member's permissions if they are part of the association - val userPermissions = association.members.find { it.uid == user!!.uid }?.role?.permissions - - // Check if the user has the "ADD_EVENTS" permission using the Permissions class - val hasAddEventsPermission = userPermissions?.hasPermission(PermissionType.ADD_EVENTS) == true if (events.isNotEmpty()) { Text( context.getString(R.string.association_upcoming_events), @@ -468,41 +1022,123 @@ private fun AssociationEvents( // See more clicked, display all events if (isSeeMoreClicked) { events.forEach { event -> - AssociationEventCard(navigationAction, event, userViewModel, eventViewModel) + AssociationEventCard( + navigationAction, event, userViewModel, eventViewModel, shouldBeEditable = false) } - // Display the first event only } else { - AssociationEventCard(navigationAction, first, userViewModel, eventViewModel) + AssociationEventCard( + navigationAction, first, userViewModel, eventViewModel, shouldBeEditable = false) } } - // Display the see more button if there are more than one event + if (events.size > 1) { AssociationProfileSeeMoreButton( { isSeeMoreClicked = false }, { isSeeMoreClicked = true }, isSeeMoreClicked) } } - // Show the "Add Event" button only if the user is a member and has the "ADD_EVENTS" permission +} + +/** + * Displays events related to an association, along with a search bar, event creation button, and + * event details. The event list is sorted and displayed using a paginated view. Users with + * appropriate permissions can create new events, and a search bar allows for event searching. + * + * @param navigationAction The navigation actions to navigate between screens. + * @param association The association whose events are being displayed. + * @param userViewModel The ViewModel responsible for managing user data. + * @param eventViewModel The ViewModel responsible for managing event data. + * @param searchViewModel The ViewModel responsible for managing event search functionality. + */ +@Composable +private fun AssociationActionsEvents( + navigationAction: NavigationAction, + association: Association, + userViewModel: UserViewModel, + eventViewModel: EventViewModel, + searchViewModel: SearchViewModel +) { + val context = LocalContext.current + val isConnected = NetworkUtils.checkInternetConnection(context) + + val events by association.events.list.collectAsState() + val user by userViewModel.user.collectAsState() + + if (user == null) { + return + } + + val isMember = association.members.any { it.uid == user!!.uid } + val userRole = + association?.roles?.find { + it.uid == association.members.find { it.uid == user!!.uid }?.roleUid + } + + val userPermissions = userRole?.permissions + val hasAddEventsPermission = + userPermissions?.hasPermission(PermissionType.ADD_EDIT_EVENTS) == true + + // Title + Text( + text = context.getString(R.string.association_profile_actions_events_text), + modifier = Modifier.testTag(AssociationProfileActionsTestTags.SMALL_EVENT_TITLE), + style = AppTypography.headlineLarge) + + // Add Event Button if (isMember && hasAddEventsPermission) { - Button( - onClick = { - if (isConnected) { - navigationAction.navigateTo(Screen.EVENT_CREATION) - } else { - ToastUtils.showToast(context, context.getString(R.string.no_internet_connection)) - } - }, - modifier = Modifier.testTag(AssociationProfileTestTags.ADD_EVENT_BUTTON), - contentPadding = ButtonDefaults.ButtonWithIconContentPadding) { - Icon( - Icons.Filled.Add, - contentDescription = context.getString(R.string.association_profile_add_event_button), - modifier = Modifier.size(ButtonDefaults.IconSize)) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(context.getString(R.string.association_profile_add_event_button)) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 0.dp), + horizontalArrangement = Arrangement.Center) { + Button( + onClick = { + if (isConnected) { + navigationAction.navigateTo(Screen.EVENT_CREATION) + } else { + ToastUtils.showToast(context, context.getString(R.string.no_internet_connection)) + } + }, + modifier = Modifier.testTag(AssociationProfileActionsTestTags.ADD_EVENT_BUTTON), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding) { + Icon( + Icons.Filled.Add, + contentDescription = + context.getString(R.string.association_profile_add_event_button), + modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(context.getString(R.string.association_profile_add_event_button)) + } } } + + // Sorted Events + val sortedEvents = events.sortedBy { it.startDate } + val pagerState = rememberPagerState { sortedEvents.size } + val coroutineScope = rememberCoroutineScope() + + if (events.isNotEmpty()) { + SearchPagerSection( + items = sortedEvents, + cardContent = { event -> + // Each event card in the HorizontalPager + AssociationEventCard( + navigationAction = navigationAction, + event = event, + userViewModel = userViewModel, + eventViewModel = eventViewModel, + shouldBeEditable = hasAddEventsPermission) + }, + pagerState = pagerState) + } } +/** + * Displays a button that toggles between "See More" and "See Less" states. This component is used + * to show or hide more content, depending on whether the button has been clicked. + * + * @param onSeeMore A lambda function that is triggered when the "See More" button is clicked. + * @param onSeeLess A lambda function that is triggered when the "See Less" button is clicked. + * @param isSeeMoreClicked A boolean flag indicating whether the "See More" button is clicked or + * not. + */ @Composable fun AssociationProfileSeeMoreButton( onSeeMore: () -> Unit, @@ -534,16 +1170,23 @@ fun AssociationProfileSeeMoreButton( } /** - * Component that display only one event in a card format, like in the home screen. + * Displays a single event inside a card format. This component is typically used in places like the + * home screen to show event details, and it includes an option for editing the event based on the + * provided permissions. * - * @param event (Event) : The event to display + * @param navigationAction The navigation actions to navigate between screens. + * @param event The event to be displayed in the card. + * @param userViewModel The ViewModel responsible for managing user data. + * @param eventViewModel The ViewModel responsible for managing event data. + * @param shouldBeEditable A flag indicating whether the event should be editable by the user. */ @Composable private fun AssociationEventCard( navigationAction: NavigationAction, event: Event, userViewModel: UserViewModel, - eventViewModel: EventViewModel + eventViewModel: EventViewModel, + shouldBeEditable: Boolean ) { Box(modifier = Modifier.testTag(AssociationProfileTestTags.EVENT_CARD + event.uid)) { EventCard( @@ -551,7 +1194,7 @@ private fun AssociationEventCard( event = event, userViewModel = userViewModel, eventViewModel = eventViewModel, - true) + shouldBeEditable = shouldBeEditable) } } @@ -629,3 +1272,79 @@ private fun AssociationHeader( } } } + +/** + * Component that display the header of the association profile screen. It display the image of the + * association, the number of followers and the number of members. It also display a button to + * follow the association. + */ +@Composable +private fun AssociationActionsHeader( + association: Association, + isFollowed: Boolean, + enableButton: Boolean, + onFollow: () -> Unit, + onClickSaveButton: () -> Unit +) { + val context = LocalContext.current + + Text( + text = context.getString(R.string.association_profile_general_actions_text), + modifier = Modifier.testTag(AssociationProfileActionsTestTags.EVENT_TITLE), + style = AppTypography.headlineLarge) + + var showNotificationDialog by remember { mutableStateOf(false) } + + NotificationSender( + context.getString(R.string.association_broadcast_message), + NotificationType.ASSOCIATION_FOLLOWERS, + association.uid, + { mapOf("title" to association.name, "body" to it) }, + showNotificationDialog, + { showNotificationDialog = false }) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 0.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.widthIn(max = 300.dp)) { + Text( + text = context.getString(R.string.association_profile_header_broadcast_text), + style = AppTypography.bodyMedium, + modifier = Modifier.padding(end = 8.dp)) + } + IconButton( + onClick = { showNotificationDialog = true }, + modifier = + Modifier.testTag(AssociationProfileActionsTestTags.BROADCAST_ICON_BUTTON) + .size(24.dp)) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = + context.getString(R.string.association_profile_header_broadcast_button), + tint = MaterialTheme.colorScheme.primary) + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 0.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.widthIn(max = 300.dp)) { + Text( + text = context.getString(R.string.association_profile_header_edit_text), + style = AppTypography.bodyMedium, + modifier = Modifier.padding(end = 8.dp)) + } + IconButton( + onClick = { onClickSaveButton() }, + modifier = + Modifier.testTag(AssociationProfileActionsTestTags.EDIT_BUTTON).size(24.dp)) { + Icon( + Icons.Filled.Edit, + contentDescription = + context.getString(R.string.association_profile_header_edit_button), + tint = MaterialTheme.colorScheme.primary) + } + } +} diff --git a/app/src/main/java/com/android/unio/ui/association/AssociationSearchBar.kt b/app/src/main/java/com/android/unio/ui/components/SearchBar.kt similarity index 60% rename from app/src/main/java/com/android/unio/ui/association/AssociationSearchBar.kt rename to app/src/main/java/com/android/unio/ui/components/SearchBar.kt index e9ac50f95..e194a9a7b 100644 --- a/app/src/main/java/com/android/unio/ui/association/AssociationSearchBar.kt +++ b/app/src/main/java/com/android/unio/ui/components/SearchBar.kt @@ -1,4 +1,4 @@ -package com.android.unio.ui.association +package com.android.unio.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -34,6 +34,106 @@ import com.android.unio.model.search.SearchViewModel import com.android.unio.model.strings.test_tags.explore.ExploreContentTestTags import com.android.unio.ui.theme.AppTypography +/** + * A general search bar composable that can be configured for different use cases. + * + * @param searchQuery The current search query. + * @param onQueryChange Callback invoked when the search query changes. + * @param results The list of search results to display. + * @param onResultClick Callback invoked when a search result is clicked. + * @param searchState The current search status. + * @param shouldCloseExpandable Whether the search bar should close the expandable when expanded. + * @param onOutsideClickHandled Callback invoked when an outside click is handled. + * @param resultContent A composable to define how each result is displayed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchBar( + searchQuery: String, + onQueryChange: (String) -> Unit, + results: List, + onResultClick: (T) -> Unit, + searchState: SearchViewModel.Status, + shouldCloseExpandable: Boolean, + onOutsideClickHandled: () -> Unit, + resultContent: @Composable (T) -> Unit +) { + var isExpanded by rememberSaveable { mutableStateOf(false) } + val context = LocalContext.current + + if (shouldCloseExpandable && isExpanded) { + isExpanded = false + onOutsideClickHandled() + } + + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + modifier = Modifier.testTag("SEARCH_BAR_INPUT"), + query = searchQuery, + onQueryChange = { onQueryChange(it) }, + onSearch = {}, + expanded = isExpanded, + onExpandedChange = { isExpanded = it }, + placeholder = { + Text( + text = context.getString(R.string.search_placeholder), + style = AppTypography.bodyLarge, + modifier = Modifier.testTag("SEARCH_BAR_PLACEHOLDER")) + }, + trailingIcon = { + Icon( + Icons.Default.Search, + contentDescription = + context.getString(R.string.explore_content_description_search_icon), + modifier = Modifier.testTag("SEARCH_TRAILING_ICON")) + }, + ) + }, + expanded = isExpanded, + onExpandedChange = { isExpanded = it }, + modifier = Modifier.padding(horizontal = 16.dp).testTag("SEARCH_BAR")) { + when (searchState) { + SearchViewModel.Status.ERROR -> { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center) { + Text(context.getString(R.string.explore_search_error_message)) + } + } + SearchViewModel.Status.LOADING -> { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center) { + LinearProgressIndicator() + } + } + SearchViewModel.Status.IDLE -> {} + SearchViewModel.Status.SUCCESS -> { + if (results.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center) { + Text(context.getString(R.string.explore_search_no_results)) + } + } else { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + results.forEach { result -> + ListItem( + modifier = + Modifier.clickable { + isExpanded = false + onResultClick(result) + }, + headlineContent = { resultContent(result) }) + } + } + } + } + } + } +} + /** * A search bar that allows users to search for associations. The last 2 parameters are used to * handle the expandable state of the search bar. For an example of how to use this, see Explore.kt diff --git a/app/src/main/java/com/android/unio/ui/components/SearchPagerSection.kt b/app/src/main/java/com/android/unio/ui/components/SearchPagerSection.kt new file mode 100644 index 000000000..8bdfbb3a1 --- /dev/null +++ b/app/src/main/java/com/android/unio/ui/components/SearchPagerSection.kt @@ -0,0 +1,161 @@ +package com.android.unio.ui.components + +import android.util.Log +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.android.unio.R +import com.android.unio.ui.theme.AppTypography +import kotlinx.coroutines.launch + +/** + * A generalized composable for displaying a search bar with a sliding pager. + * + * @param T The type of the entity (e.g., Event, Member, Association). + * @param items The list of items to display in the pager. + * @param searchViewModel The view model handling the search logic. + * @param onItemSelected Callback when an item is selected from search results. + * @param cardContent A composable lambda that defines how to render each pager content. + * @param title The title displayed above the section. + * @param searchBar Composable function for rendering the search bar. + */ +@Composable +fun SearchPagerSection( + items: List, + cardContent: @Composable (T) -> Unit, + pagerState: PagerState +) { + val context = LocalContext.current + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + + // Sliding Progress Bar (if more than one item exists) + if (items.size > 1) { + // Search Bar Composable + // Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { searchBar() } + + Text( + text = context.getString(R.string.search_pager_section_slide), + style = AppTypography.bodySmall, + modifier = Modifier.padding(vertical = 8.dp)) + ProgressBarBetweenElements(tabList = items.map { it.toString() }, pagerState = pagerState) + } + + // Horizontal Pager + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 16.dp) { page -> + val item = items[page] + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + cardContent(item) + } + } + } +} + +/** + * A composable that renders a progress bar between elements in a tab layout, based on the state of + * the pager. The progress bar visually represents the current position and movement between tabs. + * + * @param tabList The list of tab labels, corresponding to the pager's items. + * @param pagerState The state of the pager, providing the current page and offset information. + */ +@Composable +fun ProgressBarBetweenElements(tabList: List, pagerState: PagerState) { + val defaultTabWidth = 576.0F + val defaultTabHeight = 92.0F + + val scope = rememberCoroutineScope() + val colorScheme = MaterialTheme.colorScheme + val sizeList = remember { mutableStateMapOf>() } + val progressFromFirstPage by remember { + derivedStateOf { pagerState.currentPageOffsetFraction + pagerState.currentPage.dp.value } + } + + TabRow( + selectedTabIndex = pagerState.currentPage, + contentColor = colorScheme.primary, + divider = {}, + indicator = { + Box( + modifier = + Modifier.fillMaxSize().drawBehind { + val totalWidth = sizeList.values.map { it.first }.sum() + val height: Float + + if (sizeList.isEmpty()) { + Log.e("Home Page", "The size values of tabs are null, should not happen !") + height = defaultTabHeight + } else { + height = sizeList[0]?.second ?: defaultTabHeight + } + + val outerRectangleYStart = height - 45 + val outerRectangleYEnd = height - 5 + + val tabWidth = sizeList[0]?.first ?: defaultTabWidth + val rectangleStartX = progressFromFirstPage * tabWidth + tabWidth / 4 + val rectangleEndX = progressFromFirstPage * tabWidth + tabWidth * 3 / 4 + val rectangleYStart = height - 35 + val rectangleYEnd = height - 15 + + drawRoundRect( + color = colorScheme.primary.copy(alpha = 0.1f), + topLeft = Offset(x = tabWidth / 4, y = outerRectangleYStart), + size = + Size( + width = tabWidth * 7 / 2, + height = + outerRectangleYEnd - + outerRectangleYStart), // 2 * (7/2 = 1 + 3 / 4) + cornerRadius = CornerRadius(x = 16.dp.toPx(), y = 16.dp.toPx())) + + drawRoundRect( + color = colorScheme.primary.copy(alpha = 0.2f), + topLeft = Offset(x = rectangleStartX, y = rectangleYStart), + size = + Size( + width = rectangleEndX - rectangleStartX, + height = rectangleYEnd - rectangleYStart), + cornerRadius = CornerRadius(x = 12.dp.toPx(), y = 12.dp.toPx())) + + val lineStartOffset = + Offset(x = progressFromFirstPage * tabWidth + tabWidth / 3, y = height - 25) + val lineEndOffset = + Offset( + x = progressFromFirstPage * tabWidth + tabWidth * 2 / 3, y = height - 25) + + drawLine( + start = lineStartOffset, + end = lineEndOffset, + color = colorScheme.primary, + strokeWidth = Stroke.DefaultMiter) + }) + }) { + tabList.forEachIndexed { index, str -> + Tab( + selected = index == pagerState.currentPage, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + modifier = + Modifier.onSizeChanged { + sizeList[index] = Pair(it.width.toFloat(), it.height.toFloat()) + }, + selectedContentColor = colorScheme.primary) { + Spacer(modifier = Modifier.height(20.dp)) + } + } + } +} 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 60d629e6c..00b4d98e0 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 @@ -185,10 +185,9 @@ fun EventCardScaffold( R.string.event_card_content_description_edit_association), tint = Color.White) } + } else { + EventSaveButton(event, eventViewModel, userViewModel) } - Spacer(modifier = Modifier.width(2.dp)) - - EventSaveButton(event, eventViewModel, userViewModel) } } 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 5fdaf49a8..599450146 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 @@ -1,6 +1,7 @@ package com.android.unio.ui.event import android.net.Uri +import android.util.Log import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -44,6 +45,7 @@ import com.android.unio.model.event.Event import com.android.unio.model.event.EventType import com.android.unio.model.event.EventViewModel import com.android.unio.model.firestore.firestoreReferenceListWith +import com.android.unio.model.functions.addEditEventCloudFunction import com.android.unio.model.map.Location import com.android.unio.model.map.nominatim.NominatimLocationSearchViewModel import com.android.unio.model.search.SearchViewModel @@ -351,45 +353,80 @@ fun EventCreationScreen( eventBannerUri.value != Uri.EMPTY && selectedLocation != null, onClick = { - val inputStream = context.contentResolver.openInputStream(eventBannerUri.value)!! - val newEvent = - Event( - uid = "", // This gets overwritten by eventViewModel.addEvent - 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 = selectedLocation!!, - types = types.filter { it.second.value }.map { it.first }, - eventPictures = MockReferenceList(), - ) - eventViewModel.addEvent( - inputStream, - newEvent, - onSuccess = { - associationViewModel.addEventLocally(newEvent) - navigationAction.goBack() - }, - onFailure = { - Toast.makeText( - context, - context.getString(R.string.event_creation_failed), - Toast.LENGTH_SHORT) - .show() - }) + try { + val inputStream = context.contentResolver.openInputStream(eventBannerUri.value)!! + val newEvent = + Event( + uid = "", // This gets overwritten by eventViewModel.addEvent + 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 = selectedLocation!!, + types = types.filter { it.second.value }.map { it.first }, + eventPictures = MockReferenceList(), + ) + + // First step: Add the image to the event + eventViewModel.addImageToEvent( + inputStream, + newEvent, + onSuccess = { eventWithImage -> + // Second step: Call the cloud function to save the event + addEditEventCloudFunction( + eventWithImage, + associationViewModel.selectedAssociation.value!!.uid, + onSuccess = { response -> + // Handle successful cloud function execution + Log.d("EventCreation", "Event successfully created: $response") + associationViewModel.addEditEventLocally( + eventWithImage) // Update locally + eventViewModel.addEditEventLocally(eventWithImage) + navigationAction.goBack() // Navigate back + }, + onError = { error -> + // Handle error from cloud function + Log.e( + "EventCreation", + "Failed to save event via cloud function: $error") + Toast.makeText( + context, + context.getString(R.string.event_creation_failed), + Toast.LENGTH_SHORT) + .show() + }, + isNewEvent = true) + }, + onFailure = { error -> + // Handle error from adding image + Log.e("EventCreation", "Failed to add image to event: $error") + Toast.makeText( + context, + context.getString(R.string.event_creation_failed), + Toast.LENGTH_SHORT) + .show() + }) + } catch (e: Exception) { + Log.e("EventCreation", "Unexpected error during event creation: $e") + Toast.makeText( + context, + context.getString(R.string.event_creation_failed), + Toast.LENGTH_SHORT) + .show() + } }) { Text(context.getString(R.string.event_creation_save_button)) } diff --git a/app/src/main/java/com/android/unio/ui/event/EventDetails.kt b/app/src/main/java/com/android/unio/ui/event/EventDetails.kt index b71747228..962db7335 100644 --- a/app/src/main/java/com/android/unio/ui/event/EventDetails.kt +++ b/app/src/main/java/com/android/unio/ui/event/EventDetails.kt @@ -304,7 +304,10 @@ fun EventScreenContent( EventInformationCard(event, organisers, context) - SmoothTopBarNavigationMenu(tabList, pagerState) + SmoothTopBarNavigationMenu( + tabList, + pagerState, + listOf(EventDetailsTestTags.DETAILS_PAGE_HP, EventDetailsTestTags.DETAILS_IMAGE_HP)) HorizontalPager( state = pagerState, modifier = 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 index 7e37d91cb..cfea54a0d 100644 --- a/app/src/main/java/com/android/unio/ui/event/EventEdit.kt +++ b/app/src/main/java/com/android/unio/ui/event/EventEdit.kt @@ -1,6 +1,7 @@ package com.android.unio.ui.event import android.net.Uri +import android.util.Log import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -369,14 +370,22 @@ fun EventEditScreen( types = types.filter { it.second.value }.map { it.first }, eventPictures = MockReferenceList(), ) + + Log.d("AssociationViewModel", "CheckImageURI : " + eventBannerUri.toString()) // This should be extracted to a util if (checkImageUri(eventBannerUri.toString()) == ImageUriType.LOCAL) { + Log.d( + "AssociationViewModel", "CheckImageURI2 : " + eventBannerUri.toString()) val inputStream = context.contentResolver.openInputStream(eventBannerUri.value)!! + eventViewModel.updateEvent( inputStream, - updatedEvent, - onSuccess = { navigationAction.goBack() }, + updatedEvent, // OUBLIE PAS DE ADDIMAGETOEVENT + onSuccess = { + navigationAction.goBack() + associationViewModel.addEditEventLocally((updatedEvent)) + }, onFailure = { Toast.makeText( context, @@ -385,6 +394,7 @@ fun EventEditScreen( .show() }) } else { + Log.d("AssociationViewModel", "Update Event Without Image") eventViewModel.updateEventWithoutImage( updatedEvent, onSuccess = { navigationAction.goBack() }, diff --git a/app/src/main/java/com/android/unio/ui/explore/Explore.kt b/app/src/main/java/com/android/unio/ui/explore/Explore.kt index e44b2bdc8..60fc59ace 100644 --- a/app/src/main/java/com/android/unio/ui/explore/Explore.kt +++ b/app/src/main/java/com/android/unio/ui/explore/Explore.kt @@ -45,7 +45,7 @@ import com.android.unio.model.association.AssociationViewModel import com.android.unio.model.search.SearchViewModel import com.android.unio.model.strings.test_tags.explore.ExploreContentTestTags import com.android.unio.model.strings.test_tags.explore.ExploreTestTags -import com.android.unio.ui.association.AssociationSearchBar +import com.android.unio.ui.components.AssociationSearchBar import com.android.unio.ui.image.AsyncImageWrapper import com.android.unio.ui.navigation.BottomNavigationMenu import com.android.unio.ui.navigation.LIST_TOP_LEVEL_DESTINATION diff --git a/app/src/main/java/com/android/unio/ui/home/Home.kt b/app/src/main/java/com/android/unio/ui/home/Home.kt index 6b585e93a..459117b77 100644 --- a/app/src/main/java/com/android/unio/ui/home/Home.kt +++ b/app/src/main/java/com/android/unio/ui/home/Home.kt @@ -279,6 +279,7 @@ fun TopBar( listOf( context.getString(R.string.home_tab_all), context.getString(R.string.home_tab_following)) - SmoothTopBarNavigationMenu(tabList, pagerState) + SmoothTopBarNavigationMenu( + tabList, pagerState, listOf(HomeTestTags.TAB_ALL, HomeTestTags.TAB_FOLLOWING)) } } diff --git a/app/src/main/java/com/android/unio/ui/navigation/SmoothTopNavigationMenu.kt b/app/src/main/java/com/android/unio/ui/navigation/SmoothTopNavigationMenu.kt index bc9e5dbe6..79b6a8d82 100644 --- a/app/src/main/java/com/android/unio/ui/navigation/SmoothTopNavigationMenu.kt +++ b/app/src/main/java/com/android/unio/ui/navigation/SmoothTopNavigationMenu.kt @@ -26,11 +26,14 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.android.unio.model.strings.test_tags.home.HomeTestTags import kotlinx.coroutines.launch @Composable -fun SmoothTopBarNavigationMenu(tabList: List, pagerState: PagerState) { +fun SmoothTopBarNavigationMenu( + tabList: List, + pagerState: PagerState, + tabTestTags: List +) { val defaultTabWidth = 576.0F val defaultTabHeight = 92.0F @@ -74,7 +77,6 @@ fun SmoothTopBarNavigationMenu(tabList: List, pagerState: PagerState) { strokeWidth = Stroke.DefaultMiter) }) }) { - val tabTestTags = listOf(HomeTestTags.TAB_ALL, HomeTestTags.TAB_FOLLOWING) tabList.forEachIndexed { index, str -> Tab( selected = index == pagerState.currentPage, diff --git a/app/src/main/java/com/android/unio/ui/user/UserClaimAssociationPresidentialRights.kt b/app/src/main/java/com/android/unio/ui/user/UserClaimAssociationPresidentialRights.kt index af76534a4..72fc4c296 100644 --- a/app/src/main/java/com/android/unio/ui/user/UserClaimAssociationPresidentialRights.kt +++ b/app/src/main/java/com/android/unio/ui/user/UserClaimAssociationPresidentialRights.kt @@ -40,7 +40,7 @@ import com.android.unio.model.search.SearchViewModel import com.android.unio.model.strings.test_tags.user.UserClaimAssociationPresidentialRightsTestTags import com.android.unio.model.user.User import com.android.unio.model.user.UserViewModel -import com.android.unio.ui.association.AssociationSearchBar +import com.android.unio.ui.components.AssociationSearchBar import com.android.unio.ui.navigation.NavigationAction import com.android.unio.ui.navigation.Screen import com.android.unio.ui.theme.AppTypography diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 341fd2a92..1922a2867 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -38,6 +38,33 @@ Image de l\'association Icône suivre Ajouter un événement + Votre rôle est + Aperçu + Actions + bouton modifier + Modifier l\'association + bouton diffuser + Diffuser un message à tous les membres de l\'association + Actions générales + Événements + Autorisations + Choisir la couleur du rôle + Nom affiché + Modifier le rôle + Créer un nouveau rôle + Annuler + Enregistrer + Créer + Supprimer + Supprimer le rôle + Annuler + Êtes-vous sûr de vouloir supprimer le rôle + Modifier le rôle + Aperçu + Actions + Votre rôle est + Créer un nouveau rôle + Ajoutez une image comme logo de l\'association. @@ -505,4 +532,7 @@ Orientation professionnelle Inconnue + + Glissez pour voir tous les résultats + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66fa4d67a..d59b565d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,33 @@ Association image of Follow icon Add event + Your role is + Overview + Actions + edit button + Edit Association + broadcast button + Broadcast a message to all members of the association + General Actions + Events + Permissions + Choose Role Color + Display Name + Edit Role + Create New Role + Cancel + Save + Create + Delete + Delete Role + Cancel + Are you sure you want to delete the role + Edit Role + Overview + Actions + Your role is + Create New Role + @@ -553,4 +580,8 @@ sem. Morbi id leo nisi. Cras pretium orci vitae sapien aliquet aliquam. Phasellus vestibulum diam nec consequat hendrerit. + + + Slide to see all results + \ No newline at end of file diff --git a/app/src/test/java/com/android/unio/model/association/AssociationRepositoryFirestoreTest.kt b/app/src/test/java/com/android/unio/model/association/AssociationRepositoryFirestoreTest.kt index f811010dd..48a87040c 100644 --- a/app/src/test/java/com/android/unio/model/association/AssociationRepositoryFirestoreTest.kt +++ b/app/src/test/java/com/android/unio/model/association/AssociationRepositoryFirestoreTest.kt @@ -98,7 +98,7 @@ class AssociationRepositoryFirestoreTest { association1 = MockAssociation.createMockAssociation( category = AssociationCategory.SCIENCE_TECH, - members = listOf(Member(User.emptyFirestoreReferenceElement(), Role.ADMIN))) + members = listOf(Member(User.emptyFirestoreReferenceElement(), Role.ADMIN.uid))) association2 = MockAssociation.createMockAssociation(category = AssociationCategory.SCIENCE_TECH) @@ -151,8 +151,8 @@ class AssociationRepositoryFirestoreTest { "description" to association1.description, "members" to mapOf( - "1" to "Guest", - "2" to "Guest"), // the serialization process does not allow us to simply put + "1" to "GUESTUID", + "2" to "GUESTUID"), // the serialization process does not allow us to simply put // association1.members "roles" to mapOf( @@ -160,12 +160,12 @@ class AssociationRepositoryFirestoreTest { mapOf( "displayName" to "Guest", "color" to badgeColorBlue, - "permissions" to listOf("Full rights")), + "permissions" to listOf("Full Rights")), "Administrator" to mapOf( "displayName" to "Administrator", "color" to badgeColorCyan, - "permissions" to listOf("Full rights"))), + "permissions" to listOf("Full Rights"))), "followersCount" to association1.followersCount, "image" to association1.image, "events" to association1.events.uids, @@ -179,19 +179,19 @@ class AssociationRepositoryFirestoreTest { "fullName" to association2.fullName, "category" to association2.category.name, "description" to association2.description, - "members" to mapOf("1" to "Guest", "2" to "Guest"), + "members" to mapOf("1" to "GUESTUID", "2" to "GUESTUID"), "roles" to mapOf( "Guest" to mapOf( "displayName" to "Guest", "color" to badgeColorBlue, - "permissions" to listOf("Full rights")), + "permissions" to listOf("Full Rights")), "Administrator" to mapOf( "displayName" to "Administrator", "color" to badgeColorCyan, - "permissions" to listOf("Full rights"))), + "permissions" to listOf("Full Rights"))), "followersCount" to association2.followersCount, "image" to association2.image, "events" to association2.events.uids, @@ -282,7 +282,7 @@ class AssociationRepositoryFirestoreTest { fullName = "", category = AssociationCategory.ARTS, description = "", - members = listOf(Member(User.emptyFirestoreReferenceElement(), Role.GUEST)), + members = listOf(Member(User.emptyFirestoreReferenceElement(), Role.GUEST.uid)), roles = listOf(Role.GUEST), followersCount = 0, image = "", diff --git a/app/src/test/java/com/android/unio/model/firestore/HydrationAndSerializationTest.kt b/app/src/test/java/com/android/unio/model/firestore/HydrationAndSerializationTest.kt index 379e6e6c8..e0f169ea0 100644 --- a/app/src/test/java/com/android/unio/model/firestore/HydrationAndSerializationTest.kt +++ b/app/src/test/java/com/android/unio/model/firestore/HydrationAndSerializationTest.kt @@ -54,7 +54,7 @@ class HydrationAndSerializationTest { fullName = "Example Association", category = AssociationCategory.ARTS, description = "An example association", - members = listOf(Member(User.firestoreReferenceElementWith("1"), Role.GUEST)), + members = listOf(Member(User.firestoreReferenceElementWith("1"), Role.GUEST.uid)), roles = listOf(Role.GUEST), followersCount = 0, image = "https://www.example.com/image.jpg", diff --git a/app/src/test/java/com/android/unio/model/search/SearchRepositoryTest.kt b/app/src/test/java/com/android/unio/model/search/SearchRepositoryTest.kt index 9d9cd99a2..5bbc9f479 100644 --- a/app/src/test/java/com/android/unio/model/search/SearchRepositoryTest.kt +++ b/app/src/test/java/com/android/unio/model/search/SearchRepositoryTest.kt @@ -90,7 +90,7 @@ class SearchRepositoryTest { category = AssociationCategory.SCIENCE_TECH, description = "ACM is the world's largest educational and scientific computing society.", followersCount = 1, - members = listOf(Member(User.firestoreReferenceElementWith("1"), Role.GUEST)), + members = listOf(Member(User.firestoreReferenceElementWith("1"), Role.GUEST.uid)), roles = listOf(Role.GUEST), image = "https://www.example.com/image.jpg", events = Event.firestoreReferenceListWith(listOf("1", "2")), @@ -106,7 +106,7 @@ class SearchRepositoryTest { description = "IEEE is the world's largest technical professional organization dedicated to advancing technology for the benefit of humanity.", followersCount = 1, - members = listOf(Member(User.firestoreReferenceElementWith("2"), Role.GUEST)), + members = listOf(Member(User.firestoreReferenceElementWith("2"), Role.GUEST.uid)), roles = listOf(Role.GUEST), image = "https://www.example.com/image.jpg", events = Event.firestoreReferenceListWith(listOf("3", "4")), diff --git a/firebase/emulator-data/auth_export/accounts.json b/firebase/emulator-data/auth_export/accounts.json index 3a5b0798a..f688a4986 100644 --- a/firebase/emulator-data/auth_export/accounts.json +++ b/firebase/emulator-data/auth_export/accounts.json @@ -1 +1 @@ -{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"06ANeNvmuJoWmotcJZiOH3Ovyq6b","createdAt":"1732791553861","lastLoginAt":"1732791553861","displayName":"LeBronJames","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltfdHHQMVpVklmSbRChmxl:password=thePrince23","salt":"fakeSaltfdHHQMVpVklmSbRChmxl","passwordUpdatedAt":1734641604054,"providerUserInfo":[{"providerId":"password","email":"lepookie@gmail.com","federatedId":"lepookie@gmail.com","rawId":"lepookie@gmail.com","displayName":"LeBronJames","photoUrl":""}],"validSince":"1734641604","email":"lepookie@gmail.com","emailVerified":true,"disabled":false},{"localId":"3NhsOQ3gnPn4glzbAqHCYT6rB3jR","createdAt":"1732718591686","lastLoginAt":"1732722128159","displayName":"Marjolaine Lemm","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltQQMWAWcu3pLOpVGbFT47:password=oldPassword456","salt":"fakeSaltQQMWAWcu3pLOpVGbFT47","passwordUpdatedAt":1734641604054,"providerUserInfo":[{"providerId":"password","email":"exampleresetpwd@gmail.com","federatedId":"exampleresetpwd@gmail.com","rawId":"exampleresetpwd@gmail.com","displayName":"Marjolaine Lemm","photoUrl":""}],"validSince":"1734641604","email":"exampleresetpwd@gmail.com","emailVerified":true,"disabled":false},{"localId":"FifwztwnrrtfbQg201UbcjAyQX5q","createdAt":"1732022687052","lastLoginAt":"1732220483621","displayName":"Admin","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltR6hbmFf78p20xUEcFCNa:password=adminadmin9","salt":"fakeSaltR6hbmFf78p20xUEcFCNa","passwordUpdatedAt":1734641604054,"customAttributes":"{\"role\" : \"admin\"}","providerUserInfo":[{"providerId":"password","email":"admin@admin.com","federatedId":"admin@admin.com","rawId":"admin@admin.com","displayName":"Admin","photoUrl":""}],"validSince":"1734641604","email":"admin@admin.com","emailVerified":true,"disabled":false},{"localId":"HK8N5GIeY4803A14XPUq5pMh5DWQ","createdAt":"1733332745704","lastLoginAt":"1733332745704","displayName":"UserToDelete","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltxS6yhPMM3Vx3ekmkj1x7:password=userToDelete123","salt":"fakeSaltxS6yhPMM3Vx3ekmkj1x7","passwordUpdatedAt":1734641604054,"providerUserInfo":[{"providerId":"password","email":"usertodelete@gmail.com","federatedId":"usertodelete@gmail.com","rawId":"usertodelete@gmail.com","displayName":"UserToDelete","photoUrl":""}],"validSince":"1734641604","email":"usertodelete@gmail.com","emailVerified":true,"disabled":false},{"localId":"MtrAGTUFp0150o3nzwmmKSbOr1GR","createdAt":"1731612852003","lastLoginAt":"1731612852003","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltjwKI8Y5cgyD6DYZxMsrY:password=helloWorld123","salt":"fakeSaltjwKI8Y5cgyD6DYZxMsrY","passwordUpdatedAt":1734641604055,"providerUserInfo":[{"providerId":"password","email":"example1@gmail.com","federatedId":"example1@gmail.com","rawId":"example1@gmail.com","displayName":"","photoUrl":""}],"validSince":"1734641604","email":"example1@gmail.com","emailVerified":true,"disabled":false},{"localId":"RG8Rs6PwpNtqhlvWpMJVPZdoKKPN","createdAt":"1731612826397","lastLoginAt":"1732220504903","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltIcdzStBIqUzXgEATGvOF:password=password123","salt":"fakeSaltIcdzStBIqUzXgEATGvOF","passwordUpdatedAt":1734641604055,"providerUserInfo":[{"providerId":"password","email":"example2@gmail.com","federatedId":"example2@gmail.com","rawId":"example2@gmail.com","displayName":"","photoUrl":""}],"validSince":"1734641604","email":"example2@gmail.com","emailVerified":true,"disabled":false},{"localId":"nLsXBPHwCOHwz6IcpRzDkguY8LaE","createdAt":"1732134922248","lastLoginAt":"1732220508446","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltreqoKx4XoRToMUy6CYdT:password=123456","salt":"fakeSaltreqoKx4XoRToMUy6CYdT","passwordUpdatedAt":1734641604055,"providerUserInfo":[{"providerId":"password","email":"example@gmail.com","federatedId":"example@gmail.com","rawId":"example@gmail.com","displayName":"","photoUrl":""}],"validSince":"1734641604","email":"example@gmail.com","emailVerified":false,"disabled":false}]} \ No newline at end of file +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"06ANeNvmuJoWmotcJZiOH3Ovyq6b","createdAt":"1732791553861","lastLoginAt":"1732791553861","displayName":"LeBronJames","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltfdHHQMVpVklmSbRChmxl:password=thePrince23","salt":"fakeSaltfdHHQMVpVklmSbRChmxl","passwordUpdatedAt":1734679149026,"providerUserInfo":[{"providerId":"password","email":"lepookie@gmail.com","federatedId":"lepookie@gmail.com","rawId":"lepookie@gmail.com","displayName":"LeBronJames","photoUrl":""}],"validSince":"1734679149","email":"lepookie@gmail.com","emailVerified":true,"disabled":false},{"localId":"3NhsOQ3gnPn4glzbAqHCYT6rB3jR","createdAt":"1732718591686","lastLoginAt":"1732722128159","displayName":"Marjolaine Lemm","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltQQMWAWcu3pLOpVGbFT47:password=oldPassword456","salt":"fakeSaltQQMWAWcu3pLOpVGbFT47","passwordUpdatedAt":1734679149027,"providerUserInfo":[{"providerId":"password","email":"exampleresetpwd@gmail.com","federatedId":"exampleresetpwd@gmail.com","rawId":"exampleresetpwd@gmail.com","displayName":"Marjolaine Lemm","photoUrl":""}],"validSince":"1734679149","email":"exampleresetpwd@gmail.com","emailVerified":true,"disabled":false},{"localId":"FifwztwnrrtfbQg201UbcjAyQX5q","createdAt":"1732022687052","lastLoginAt":"1734679706096","displayName":"Admin","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltR6hbmFf78p20xUEcFCNa:password=adminadmin9","salt":"fakeSaltR6hbmFf78p20xUEcFCNa","passwordUpdatedAt":1734679149027,"customAttributes":"{\"role\" : \"admin\"}","providerUserInfo":[{"providerId":"password","email":"admin@admin.com","federatedId":"admin@admin.com","rawId":"admin@admin.com","displayName":"Admin","photoUrl":""}],"validSince":"1734679149","email":"admin@admin.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2024-12-20T07:28:26.097Z"},{"localId":"HK8N5GIeY4803A14XPUq5pMh5DWQ","createdAt":"1733332745704","lastLoginAt":"1733332745704","displayName":"UserToDelete","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltxS6yhPMM3Vx3ekmkj1x7:password=userToDelete123","salt":"fakeSaltxS6yhPMM3Vx3ekmkj1x7","passwordUpdatedAt":1734679149027,"providerUserInfo":[{"providerId":"password","email":"usertodelete@gmail.com","federatedId":"usertodelete@gmail.com","rawId":"usertodelete@gmail.com","displayName":"UserToDelete","photoUrl":""}],"validSince":"1734679149","email":"usertodelete@gmail.com","emailVerified":true,"disabled":false},{"localId":"MtrAGTUFp0150o3nzwmmKSbOr1GR","createdAt":"1731612852003","lastLoginAt":"1731612852003","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltjwKI8Y5cgyD6DYZxMsrY:password=helloWorld123","salt":"fakeSaltjwKI8Y5cgyD6DYZxMsrY","passwordUpdatedAt":1734679149027,"providerUserInfo":[{"providerId":"password","email":"example1@gmail.com","federatedId":"example1@gmail.com","rawId":"example1@gmail.com","displayName":"","photoUrl":""}],"validSince":"1734679149","email":"example1@gmail.com","emailVerified":true,"disabled":false},{"localId":"RG8Rs6PwpNtqhlvWpMJVPZdoKKPN","createdAt":"1731612826397","lastLoginAt":"1732220504903","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltIcdzStBIqUzXgEATGvOF:password=password123","salt":"fakeSaltIcdzStBIqUzXgEATGvOF","passwordUpdatedAt":1734679149027,"providerUserInfo":[{"providerId":"password","email":"example2@gmail.com","federatedId":"example2@gmail.com","rawId":"example2@gmail.com","displayName":"","photoUrl":""}],"validSince":"1734679149","email":"example2@gmail.com","emailVerified":true,"disabled":false},{"localId":"nLsXBPHwCOHwz6IcpRzDkguY8LaE","createdAt":"1732134922248","lastLoginAt":"1732220508446","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltreqoKx4XoRToMUy6CYdT:password=123456","salt":"fakeSaltreqoKx4XoRToMUy6CYdT","passwordUpdatedAt":1734679149027,"providerUserInfo":[{"providerId":"password","email":"example@gmail.com","federatedId":"example@gmail.com","rawId":"example@gmail.com","displayName":"","photoUrl":""}],"validSince":"1734679149","email":"example@gmail.com","emailVerified":false,"disabled":false}]} \ No newline at end of file diff --git a/firebase/emulator-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/firebase/emulator-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index 9f2ce3a4d..c9af6959f 100644 Binary files a/firebase/emulator-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/firebase/emulator-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/firebase/emulator-data/firestore_export/all_namespaces/all_kinds/output-0 b/firebase/emulator-data/firestore_export/all_namespaces/all_kinds/output-0 index 55164951a..8596ccf42 100644 Binary files a/firebase/emulator-data/firestore_export/all_namespaces/all_kinds/output-0 and b/firebase/emulator-data/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/functions/index.js b/functions/index.js index 54a76ec25..32f7f21c7 100644 --- a/functions/index.js +++ b/functions/index.js @@ -3,10 +3,13 @@ const { logger } = require("firebase-functions"); const { onRequest } = require("firebase-functions/v2/https"); const nodemailer = require('nodemailer'); +const admin = require("firebase-admin"); +const { getAuth } = require('firebase-admin/auth'); + // The Firebase Admin SDK to access Firestore. const { initializeApp } = require("firebase-admin/app"); -const { getFirestore, Timestamp } = require("firebase-admin/firestore"); +const { getFirestore, Timestamp, FieldValue } = require("firebase-admin/firestore"); const { getMessaging } = require("firebase-admin/messaging"); @@ -20,6 +23,72 @@ initializeApp(); const db = getFirestore(); const messaging = getMessaging(); +/** + * Verifies the user's ID token and returns the user's UID or an error. + * + * @param {string} tokenId - The ID token of the user. + * @returns {Promise} - A promise that resolves to the UID of the user or rejects with an error. + */ +async function getCurrentUserUid(tokenId) { + try { + if (!tokenId) { + throw new Error("No token provided"); + } + + const decodedToken = await getAuth().verifyIdToken(tokenId); + return decodedToken.uid; // Return the UID of the user + } catch (error) { + console.error("Error verifying ID token:", error); + throw new Error("Unauthorized: Invalid token"); // Re-throw the error for the caller to handle + } +} + +/** + * Function to check if a user has a specific permission. + * @param {Set} grantedPermissions - Set of the user's permissions. + * @param {string} requiredPermission - The permission to check. + * @returns {boolean} - True if the permission is granted, false otherwise. + */ +function hasPermission(grantedPermissions, requiredPermission) { + console.log("User has permission ? : ", requiredPermission) + console.log("as his permissions are: ", grantedPermissions) + return ( + grantedPermissions.has(requiredPermission) || + (grantedPermissions.has("Full Rights") && requiredPermission !== "Owner") + ); +} + +/** +* Function to hydrate roles from raw data (similar to the Kotlin logic). +* @param {Object} rolesMap - Map of roles with their data. +* @returns {Array} - Array of hydrated roles. +*/ +function hydrateRoles(rolesMap) { + return Object.entries(rolesMap).map(([roleUid, roleData]) => ({ + uid: roleUid, + displayName: roleData.displayName || "", + color: roleData.color || 0xFFFF0000, + permissions: new Set(roleData.permissions || []), + })); +} + +/** +* Function to hydrate members and link roles. +* @param {Object} membersMap - Map of members with their role UIDs. +* @param {Array} roles - Array of hydrated roles. +* @returns {Array} - Array of hydrated members. +*/ +function hydrateMembers(membersMap, roles) { + return Object.entries(membersMap).map(([userUid, roleUid]) => { + const role = roles.find((r) => r.uid === roleUid) || { + uid: "GUEST", + displayName: "Guest", + permissions: new Set(), + }; + return { userUid, role }; + }); +} + /** * Sends a verification email to the user with a 6-digit code. */ @@ -74,39 +143,351 @@ exports.verifyCode = onRequest(async (req, res) => { try { const code = req.body.data?.code; const associationUid = req.body.data?.associationUid; - const userUid = req.body.data?.userUid + const userUid = req.body.data?.userUid; - if (!code || !associationUid) { - return res.status(400).json({ message: "invalid-request", error: "Code and associationUid are required." }); + if (!code || !associationUid || !userUid) { + return res.status(400).json({ + message: "invalid-request", + error: "Code, associationUid, and userUid are required.", + }); } - const verificationDoc = await db.collection('emailVerifications').doc(associationUid).get(); + const verificationDoc = await db.collection("emailVerifications").doc(associationUid).get(); if (!verificationDoc.exists) { - return res.status(404).json({ message: "not-found", error: "Verification document not found." }); + return res.status(404).json({ + message: "not-found", + error: "Verification document not found.", + }); } const verificationData = verificationDoc.data(); const currentTime = Timestamp.now(); const codeGeneratedTime = verificationData.timestamp; - if (verificationData.code === code && currentTime.seconds - codeGeneratedTime.seconds < 600) { - await db.collection('emailVerifications').doc(associationUid).update({ status: 'verified' }); - await db.collection('associations').doc(associationUid).update({ - adminUid: userUid, // Add user.uid to the admins array + if ( + verificationData.code === code && + currentTime.seconds - codeGeneratedTime.seconds < 600 + ) { + await db + .collection("emailVerifications") + .doc(associationUid) + .update({ status: "verified" }); + + // Fetch the association document + const associationDocRef = db.collection("associations").doc(associationUid); + const associationDoc = await associationDocRef.get(); + + if (!associationDoc.exists) { + return res.status(404).json({ + message: "not-found", + error: "Association not found.", + }); + } + + const associationData = associationDoc.data(); + const existingRoles = associationData.roles || {}; + const existingMembers = associationData.members || {}; + + // Function to generate an 8-digit unique ID + const generateUniqueRoleUid = () => { + return Math.floor(10000000 + Math.random() * 90000000).toString(); + }; + + // Generate a unique role ID that doesn't exist in current roles + let newRoleUid; + do { + newRoleUid = generateUniqueRoleUid(); + } while (existingRoles[newRoleUid]); + + // Create the new Owner role + const ownerRole = { + displayName: "Owner", + permissions: ["Owner", "Full Rights"], + color: 0xFFFF0000, // Default red color + }; + + // Update the roles with the new Owner role + const updatedRoles = { + ...existingRoles, + [newRoleUid]: ownerRole, + }; + + // Update the members to map the user to the new Owner role + const updatedMembers = { + ...existingMembers, + [userUid]: newRoleUid, + }; + + // Update the association document with new roles and members + await associationDocRef.update({ + roles: updatedRoles, + members: updatedMembers, }); + return res.status(200).json({ data: "Verification successful" }); } else { - // This case is specifically for incorrect or expired code - return res.status(400).json({ message: "invalid-code", error: "The code is invalid or has expired." }); + return res.status(400).json({ + message: "invalid-code", + error: "The code is invalid or has expired.", + }); } } catch (error) { - // General catch-all error handler for unexpected issues - console.error(error); - return res.status(500).json({ message: "server-error", error: "An unexpected error occurred." }); + console.error("Error in verifyCode:", error.message); + return res.status(500).json({ + message: "server-error", + error: "An unexpected error occurred.", + }); + } +}); + + +/** + * Adds or updates a role in an association in Firestore. + * + * @param {Object} role - The role to add or update. Must include `uid`, `displayName`, `permissions` (array), and optionally `color`. + * @param {Object} associationDocRef - Firestore reference to the association document. + * @param {boolean} isNewRole - Determines whether the role is new or being updated. + * @returns {Promise} - Resolves if the role is added/updated successfully, otherwise throws an error. + * @throws {Error} - If the role data is invalid or if there's a conflict when adding a new role. + */ +async function addOrUpdateRoleInAssociation(role, associationDocRef, isNewRole) { + if (!role || !role.uid || !role.displayName || !Array.isArray(role.permissions)) { + console.log("Error: Invalid role data. Role must have `uid`, `displayName`, and `permissions`."); + throw new Error("Invalid role data. Role must have `uid`, `displayName`, and `permissions`."); + } + + // Fetch the association document + const associationDoc = await associationDocRef.get(); + + if (!associationDoc.exists) { + console.log("Error: Association not found."); + throw new Error("Association not found."); + } + + const associationData = associationDoc.data(); + const existingRoles = associationData.roles || {}; + + if (isNewRole) { + // Check if the role already exists when adding a new role + if (existingRoles[role.uid]) { + console.log("Error: Role with this UID already exists."); + throw new Error("Role with this UID already exists."); + } + } else { + // Check if the role exists when updating + if (!existingRoles[role.uid]) { + console.log("Error: Role with this UID does not exist."); + throw new Error("Role with this UID does not exist."); + } + } + + // Prepare the role data for Firestore + const updatedRoleData = { + displayName: role.displayName, + color: role.color || 0xFFFF0000, // Default color + permissions: role.permissions, // List of permissions + }; + + // Update the roles in Firestore + const updatedRoles = { + ...existingRoles, + [role.uid]: updatedRoleData, + }; + + await associationDocRef.update({ roles: updatedRoles }); + + console.log(`Role ${role.uid} ${isNewRole ? "added to" : "updated in"} association ${associationDocRef.id}`); +} + +// Cloud Function to save or update events. +exports.saveEvent = onRequest(async (req, res) => { + try { + const tokenId = req.body.data?.tokenId; // Token ID of the user + const event = req.body.data?.event; // Event data to save or update + const associationUid = req.body.data?.associationUid; // Association UID the event is associated with + const isNewEvent = req.body.data?.isNewEvent; // Boolean indicating whether it's a new event + + if (!tokenId || !event || !associationUid || typeof isNewEvent !== "boolean") { + return res.status(400).json({ message: "Missing or invalid required parameters" }); + } + + + + // Convert string back to Firebase Timestamp + const startDateString = event.startDate; + const endDateString = event.endDate; + + // Check if the strings are valid dates and convert to Firebase Timestamp + let startDate, endDate; + if (startDateString && endDateString) { + startDate = Timestamp.fromDate(new Date(startDateString)); // Convert string to Date and then to Timestamp + endDate = Timestamp.fromDate(new Date(endDateString)); // Convert string to Date and then to Timestamp + + console.log('Start Date:', startDate.toDate()); // Log the converted date + console.log('End Date:', endDate.toDate()); // Log the converted date + } else { + throw new functions.https.HttpsError('invalid-argument', 'Start date and end date are required'); + } + + // Ensure the event object is updated with the correct Timestamp fields + event.startDate = startDate; + event.endDate = endDate; + + // Get the UID of the current user + const uid = await getCurrentUserUid(tokenId); + + // Fetch the association document + const associationDocRef = db.collection("associations").doc(associationUid); + const associationDoc = await associationDocRef.get(); + + if (!associationDoc.exists) { + return res.status(404).json({ message: "Association not found." }); + } + + const associationData = associationDoc.data(); + + // Hydrate roles and members + const roles = hydrateRoles(associationData.roles || {}); + const members = hydrateMembers(associationData.members || {}, roles); + + // Find the current user and their role + const currentMember = members.find((member) => member.userUid === uid); + if (!currentMember) { + return res.status(403).json({ message: "User is not a member of the association." }); + } + + const userPermissions = currentMember.role.permissions; + + // Check if the user has the required permission + if (!hasPermission(userPermissions, "Add & Edit Event")) { + return res.status(403).json({ message: "Permission denied: ADD_EDIT_EVENT required for this association." }); + } + + // Save or update the event + const eventsCollectionRef = db.collection("events"); + + if (isNewEvent) { + // Save a new event + const newEventRef = eventsCollectionRef.doc(); + event.uid = newEventRef.id; // Assign UID to the event + await newEventRef.set(event); + + // Debugging: Check if the association data contains events + console.log("Association Data:", associationData); + + // Ensure 'events' field exists and is an array, then update the association document + let currentEvents = associationData.events || []; // Default to an empty array if 'events' doesn't exist + console.log("Current events in the association:", currentEvents); + + // Check if currentEvents is an array + if (!Array.isArray(currentEvents)) { + console.error("Error: 'events' field is not an array"); + return res.status(500).json({ message: "'events' field should be an array." }); + } + + // Now we are safe to use arrayUnion + console.log("Updating association with event UID:", event.uid); + + + // Add the event UID to the association's events array + await associationDocRef.update({ + events: FieldValue.arrayUnion(event.uid) // Add the event UID to the events array + }); + + return res.status(200).json({ + data: `Event created successfully`, + eventUid: event.uid, + }); + } else { + // Update an existing event + if (!event.uid) { + return res.status(400).json({ message: "Event UID is required for updating." }); + } + const eventDocRef = eventsCollectionRef.doc(event.uid); + await eventDocRef.update(event); + + return res.status(200).json({ + data: `Event updated successfully`, + eventUid: event.uid, + }); + } + } catch (error) { + console.error("Error in saveEvent function:", error.message); + return res.status(500).json({ message: "server-error", error: error.message }); + } +}); + + +// Updated Cloud Function +exports.saveRole = onRequest(async (req, res) => { + try { + const tokenId = req.body.data?.tokenId; // Token ID given by the user + const role = req.body.data?.role; // Role to add or update + const isNewRole = req.body.data?.isNewRole; // Boolean indicating whether it's a new role + const associationUid = req.body.data?.associationUid; // Association UID from the client + + if (!tokenId || !role || !associationUid || typeof isNewRole !== "boolean") { + console.log("is isNewRole a Boolean :", (typeof isNewRole)); + console.log("tokenId : ", tokenId) + console.log("role : ", role) + console.log("associationUid : ", associationUid) + return res.status(400).json({ message: "Missing or invalid required parameters" }); + } + + console.log("Role Data:", role); + console.log("Association UID:", associationUid); + console.log("isNewRole:", isNewRole); + + // Get the UID of the current user + const uid = await getCurrentUserUid(tokenId); + console.log("User UID:", uid); + + // Fetch the association document reference + const firestore = getFirestore(); + const associationDocRef = firestore.collection("associations").doc(associationUid); + + // Fetch association data for permission checks + const associationDoc = await associationDocRef.get(); + if (!associationDoc.exists) { + console.log("Error: Association not found."); + return res.status(404).json({ message: "Association not found." }); + } + + const associationData = associationDoc.data(); + + // Hydrate roles and members + const roles = hydrateRoles(associationData.roles || {}); + const members = hydrateMembers(associationData.members || {}, roles); + + // Find the current user and their role + const currentMember = members.find((member) => member.userUid === uid); + if (!currentMember) { + console.log(members); + console.log("Error: User is not a member of the association."); + return res.status(403).json({ message: "User is not a member of the association." }); + } + + const userPermissions = currentMember.role.permissions; + + // Check if the user has the required permission + if (!hasPermission(userPermissions, "Add & Edit Roles")) { + console.log("Permission denied: ADD_EDIT_ROLES required for this association."); + return res.status(403).json({ message: "Permission denied: ADD_EDIT_ROLES required for this association." }); + } + + // Add or update the role in the association + await addOrUpdateRoleInAssociation(role, associationDocRef, isNewRole); + console.log(`Role ${isNewRole ? "added" : "updated"} successfully.`); + return res.status(200).json({ data: `Role ${isNewRole ? "added" : "updated"} successfully`, userId: uid }); + } catch (error) { + console.error("Error in addRole function:", error.message); + return res.status(500).json({ message: "server-error", error: error.message }); } }); + + /** * Broadcasts a message to a topic. *