diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f9b5463e..a5c29a30a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -141,6 +141,9 @@ dependencies { implementation("androidx.compose.material3:material3-android:1.2.1") implementation("androidx.compose.material:material-icons-extended") // extra icons + // For pull to refresh + implementation("androidx.compose.material:material:1.6.7") + // Navigation val navVersion = "2.7.7" implementation("androidx.navigation:navigation-fragment-ktx:$navVersion") diff --git a/app/src/androidTest/java/com/github/se/assocify/Epic1Test.kt b/app/src/androidTest/java/com/github/se/assocify/Epic1Test.kt index 13106c4c6..fab2b2e24 100644 --- a/app/src/androidTest/java/com/github/se/assocify/Epic1Test.kt +++ b/app/src/androidTest/java/com/github/se/assocify/Epic1Test.kt @@ -201,10 +201,6 @@ class Epic1Test : TestCase(kaspressoBuilder = Kaspresso.Builder.withComposeSuppo onNodeWithTag("create").performClick() verify { associationAPI.addAssociation(any(), any(), any()) } - val toHome = navController.currentBackStackEntry?.destination?.route - assert(toHome == Destination.Home.route) - - onNodeWithTag("homeScreen").assertIsDisplayed() onNodeWithTag("mainNavBarItem/profile").assertIsDisplayed().performClick() // go to my profile diff --git a/app/src/androidTest/java/com/github/se/assocify/Epic4Test.kt b/app/src/androidTest/java/com/github/se/assocify/Epic4Test.kt index 343858ae9..edd139a2a 100644 --- a/app/src/androidTest/java/com/github/se/assocify/Epic4Test.kt +++ b/app/src/androidTest/java/com/github/se/assocify/Epic4Test.kt @@ -219,10 +219,10 @@ class Epic4Test : TestCase(kaspressoBuilder = Kaspresso.Builder.withComposeSuppo verify { associationAPI.addAssociation(any(), any(), any()) } assert(listAsso.contains(placeholderAssociations[0])) - // Arrives at home screen with association1 as the current association + // Arrives at treasury screen with association1 as the current association val toHome = navController.currentBackStackEntry?.destination?.route - assert(toHome == Destination.Home.route) - onNodeWithTag("homeScreen").assertIsDisplayed() + assert(toHome == Destination.Treasury.route) + onNodeWithTag("treasuryScreen").assertIsDisplayed() // Goes to the profile screen to check that and go to add their other association onNodeWithTag("mainNavBarItem/profile").assertIsDisplayed().performClick() diff --git a/app/src/androidTest/java/com/github/se/assocify/composables/MainNavigationBarTest.kt b/app/src/androidTest/java/com/github/se/assocify/composables/MainNavigationBarTest.kt index e13c8e4ce..24d8e8fe3 100644 --- a/app/src/androidTest/java/com/github/se/assocify/composables/MainNavigationBarTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/composables/MainNavigationBarTest.kt @@ -33,7 +33,7 @@ class MainNavigationBarTest : TestCase(kaspressoBuilder = Kaspresso.Builder.with MainNavigationBar( onTabSelect = { tabPressed = it }, tabList = MAIN_TABS_LIST, - selectedTab = Destination.Home) + selectedTab = Destination.Treasury) } } @@ -41,7 +41,7 @@ class MainNavigationBarTest : TestCase(kaspressoBuilder = Kaspresso.Builder.with fun tabsDisplayed() { with(composeTestRule) { onNodeWithTag("mainNavBar").assertIsDisplayed() - onNodeWithTag("mainNavBar").onChild().onChildren().assertCountEquals(5) + onNodeWithTag("mainNavBar").onChild().onChildren().assertCountEquals(3) } } @@ -50,9 +50,7 @@ class MainNavigationBarTest : TestCase(kaspressoBuilder = Kaspresso.Builder.with with(composeTestRule) { onNodeWithTag("mainNavBarItem/event").assertTextContains("Event") onNodeWithTag("mainNavBarItem/profile").assertTextContains("Profile") - onNodeWithTag("mainNavBarItem/chat").assertTextContains("Chat") onNodeWithTag("mainNavBarItem/treasury").assertTextContains("Treasury") - onNodeWithTag("mainNavBarItem/home").assertTextContains("Home") } } @@ -61,9 +59,7 @@ class MainNavigationBarTest : TestCase(kaspressoBuilder = Kaspresso.Builder.with with(composeTestRule) { onNodeWithTag("eventIcon", useUnmergedTree = true).assertIsDisplayed() onNodeWithTag("profileIcon", useUnmergedTree = true).assertIsDisplayed() - onNodeWithTag("chatIcon", useUnmergedTree = true).assertIsDisplayed() onNodeWithTag("treasuryIcon", useUnmergedTree = true).assertIsDisplayed() - onNodeWithTag("homeIcon", useUnmergedTree = true).assertIsDisplayed() } } @@ -74,12 +70,8 @@ class MainNavigationBarTest : TestCase(kaspressoBuilder = Kaspresso.Builder.with assert(tabPressed == Destination.Event) onNodeWithTag("mainNavBarItem/profile").performClick() assert(tabPressed == Destination.Profile) - onNodeWithTag("mainNavBarItem/chat").performClick() - assert(tabPressed == Destination.Chat) onNodeWithTag("mainNavBarItem/treasury").performClick() assert(tabPressed == Destination.Treasury) - onNodeWithTag("mainNavBarItem/home").performClick() - assert(tabPressed == Destination.Home) } } } diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/AccountingScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/AccountingScreenTest.kt index 4f99d2279..941a91e47 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/AccountingScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/AccountingScreenTest.kt @@ -1,5 +1,6 @@ package com.github.se.assocify.screens +import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createComposeRule @@ -25,6 +26,7 @@ import com.github.se.assocify.ui.screens.treasury.accounting.AccountingFilterBar import com.github.se.assocify.ui.screens.treasury.accounting.AccountingPage import com.github.se.assocify.ui.screens.treasury.accounting.AccountingScreen import com.github.se.assocify.ui.screens.treasury.accounting.AccountingViewModel +import com.github.se.assocify.ui.util.SnackbarSystem import com.kaspersky.components.composesupport.config.withComposeSupport import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase @@ -151,7 +153,11 @@ class AccountingScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withC CurrentUser.associationUid = "associationId" accountingViewModel = AccountingViewModel( - mockAccountingCategoryAPI, mockAccountingSubCategoryAPI, mockBalanceAPI, mockBudgetAPI) + mockAccountingCategoryAPI, + mockAccountingSubCategoryAPI, + mockBalanceAPI, + mockBudgetAPI, + SnackbarSystem(SnackbarHostState())) composeTestRule.setContent { AccountingScreen(AccountingPage.BUDGET, mockNavActions, accountingViewModel) AccountingFilterBar(accountingViewModel = accountingViewModel) diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/AddSubcategoryDialogTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/AddSubcategoryDialogTest.kt index d98e5f1b2..721960fb8 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/AddSubcategoryDialogTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/AddSubcategoryDialogTest.kt @@ -1,5 +1,6 @@ package com.github.se.assocify.screens +import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -18,6 +19,7 @@ import com.github.se.assocify.model.entities.BalanceItem import com.github.se.assocify.model.entities.BudgetItem import com.github.se.assocify.ui.screens.treasury.accounting.AccountingViewModel import com.github.se.assocify.ui.screens.treasury.accounting.accountingComposables.AddSubcategoryDialog +import com.github.se.assocify.ui.util.SnackbarSystem import com.kaspersky.components.composesupport.config.withComposeSupport import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase @@ -83,7 +85,8 @@ class AddSubcategoryDialogTest : accountingCategoryAPI = accountingCategoryAPI, accountingSubCategoryAPI = accountingSubCategoryAPI, balanceAPI = balanceAPI, - budgetAPI = budgetAPI) + budgetAPI = budgetAPI, + SnackbarSystem(SnackbarHostState())) composeTestRule.setContent { AddSubcategoryDialog(viewModel) } viewModel.showNewSubcategoryDialog() } diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/BalanceDetailedScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/BalanceDetailedScreenTest.kt index 573dac94e..8ca6e8cd7 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/BalanceDetailedScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/BalanceDetailedScreenTest.kt @@ -404,7 +404,9 @@ class BalanceDetailedScreenTest : } with(composeTestRule) { balanceDetailedViewModel.loadBalanceDetails() - onNodeWithTag("errorMessage").assertIsDisplayed().assertTextContains("Error loading category") + onNodeWithTag("errorMessage") + .assertIsDisplayed() + .assertTextContains("Error loading balance category") } } diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/BudgetDetailedScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/BudgetDetailedScreenTest.kt index 37850b8b2..481522717 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/BudgetDetailedScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/BudgetDetailedScreenTest.kt @@ -407,6 +407,21 @@ class BudgetDetailedScreenTest : } } + @Test + fun testNegativeAmount() { + with(composeTestRule) { + onNodeWithTag("createNewItem").performClick() + onNodeWithTag("editDialogBox").assertIsDisplayed() + onNodeWithTag("editNameBox").performTextClearance() + onNodeWithTag("editNameBox").performTextInput("fees") + onNodeWithTag("editAmountBox").performTextClearance() + onNodeWithTag("editAmountBox").performTextInput("-5") + onNodeWithTag("editConfirmButton").performClick() + assert(!budgetDetailedViewModel.uiState.value.amountError) + onNodeWithTag("editDialogBox").assertIsNotDisplayed() + } + } + @Test fun deleteTest() { with(composeTestRule) { diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/ChatScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/ChatScreenTest.kt deleted file mode 100644 index 9e49de352..000000000 --- a/app/src/androidTest/java/com/github/se/assocify/screens/ChatScreenTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.se.assocify.screens - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.github.se.assocify.navigation.NavigationActions -import com.github.se.assocify.ui.screens.chat.ChatScreen -import com.kaspersky.components.composesupport.config.withComposeSupport -import com.kaspersky.kaspresso.kaspresso.Kaspresso -import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import io.mockk.every -import io.mockk.mockk -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChatScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withComposeSupport()) { - @get:Rule val composeTestRule = createComposeRule() - - private val navActions = mockk() - private var tabSelected = false - - @Before - fun testSetup() { - every { navActions.navigateToMainTab(any()) } answers { tabSelected = true } - composeTestRule.setContent { ChatScreen(navActions = navActions) } - } - - @Test - fun display() { - with(composeTestRule) { - onNodeWithTag("chatScreen").assertIsDisplayed() - onNodeWithTag("mainNavBar").assertIsDisplayed() - } - } - - @Test - fun navigate() { - with(composeTestRule) { - onNodeWithTag("mainNavBarItem/treasury").performClick() - assert(tabSelected) - } - } -} diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/CreateAssoScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/CreateAssoScreenTest.kt index 5bc220c26..9c3573f1f 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/CreateAssoScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/CreateAssoScreenTest.kt @@ -181,7 +181,7 @@ class CreateAssoScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withC with(composeTestRule) { onNodeWithTag("create").performClick() verify { bigView.saveAsso() } - verify { mockNavActions.navigateToMainTab(Destination.Home) } + verify { mockNavActions.navigateToMainTab(Destination.Treasury) } } } diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/EventScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/EventScreenTest.kt index 07f3398e8..f29c65200 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/EventScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/EventScreenTest.kt @@ -45,15 +45,7 @@ class EventScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCompos fun testSetup() { every { mockEventAPI.getEvents(any(), any()) } answers { - val e1 = - Event( - "eventUID", - "testEvent1", - "a", - OffsetDateTime.now(), - OffsetDateTime.now(), - "me", - "46.518726,6.566613") + val e1 = Event("eventUID", "testEvent1", "a") val onSuccessCallback = arg<(List) -> Unit>(0) onSuccessCallback(listOf(e1)) } @@ -134,16 +126,7 @@ class EventScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCompos fun testFilterChipShowAssoc() { every { mockEventAPI.getEvents(any(), any()) } answers { - val events = - listOf( - Event( - "1", - "filterChipTestEvent1", - "a", - OffsetDateTime.now(), - OffsetDateTime.now(), - "me", - "home")) + val events = listOf(Event("1", "filterChipTestEvent1", "a")) val onSuccessCallback = firstArg<(List) -> Unit>() onSuccessCallback(events) } diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/HomeScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/HomeScreenTest.kt deleted file mode 100644 index 4016ff444..000000000 --- a/app/src/androidTest/java/com/github/se/assocify/screens/HomeScreenTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.github.se.assocify.screens - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.github.se.assocify.navigation.NavigationActions -import com.github.se.assocify.ui.screens.home.HomeScreen -import com.kaspersky.components.composesupport.config.withComposeSupport -import com.kaspersky.kaspresso.kaspresso.Kaspresso -import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import io.mockk.every -import io.mockk.mockk -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class HomeScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withComposeSupport()) { - @get:Rule val composeTestRule = createComposeRule() - - private val navActions = mockk() - private var tabSelected = false - - @Before - fun testSetup() { - every { navActions.navigateToMainTab(any()) } answers { tabSelected = true } - composeTestRule.setContent { HomeScreen(navActions = navActions) } - } - - @Test - fun display() { - with(composeTestRule) { onNodeWithTag("homeScreen").assertIsDisplayed() } - } - - @Test - fun navigate() { - with(composeTestRule) { - onNodeWithTag("mainNavBarItem/treasury").performClick() - assert(tabSelected) - } - } -} diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/ScheduleScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/ScheduleScreenTest.kt index a0919a00c..bcacc9ca8 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/ScheduleScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/ScheduleScreenTest.kt @@ -1,5 +1,6 @@ package com.github.se.assocify.screens +import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -9,6 +10,7 @@ import com.github.se.assocify.model.entities.Task import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.screens.event.scheduletab.EventScheduleScreen import com.github.se.assocify.ui.screens.event.scheduletab.EventScheduleViewModel +import com.github.se.assocify.ui.util.SnackbarSystem import com.kaspersky.components.composesupport.config.withComposeSupport import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase @@ -40,23 +42,15 @@ class ScheduleScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCom "Location", "eventUid")) - private val events: List = - listOf( - Event( - "eventUid", - "Event 1", - "Event 1", - OffsetDateTime.now(), - OffsetDateTime.now(), - "Guests?", - "BC")) + private val events: List = listOf(Event("eventUid", "Event 1", "Event 1")) private val taskAPI: TaskAPI = mockk() { every { getTasks(any(), any()) } answers { firstArg<(List) -> Unit>().invoke(tasks) } } private val navActions = mockk(relaxUnitFun = true) - private val viewModel = EventScheduleViewModel(navActions, taskAPI) + private val viewModel = + EventScheduleViewModel(navActions, taskAPI, SnackbarSystem(SnackbarHostState())) @Before fun testSetup() { diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/TaskScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/TaskScreenTest.kt index 980295bd5..401fd4f1b 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/TaskScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/TaskScreenTest.kt @@ -40,24 +40,8 @@ class TaskScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCompose private val eventList = listOf( - Event( - "testEvent", - "testEvent1", - "Test Event", - OffsetDateTime.now(), - OffsetDateTime.now(), - "5", - "2022-01-01", - ), - Event( - "testEvent2", - "testEvent2", - "Test Event 2", - OffsetDateTime.now(), - OffsetDateTime.now(), - "10", - "2022-01-01", - )) + Event("testEvent", "testEvent1", "Test Event"), + Event("testEvent2", "testEvent2", "Test Event 2")) private val navActions = mockk(relaxUnitFun = true) private val eventAPI = @@ -242,24 +226,8 @@ class EditTaskScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCom private val eventList = listOf( - Event( - "testEvent1", - "testEvent1", - "Test Event", - OffsetDateTime.now(), - OffsetDateTime.now(), - "5", - "2022-01-01", - ), - Event( - "testEvent2", - "testEvent2", - "Test Event 2", - OffsetDateTime.now(), - OffsetDateTime.now(), - "10", - "2022-01-01", - )) + Event("testEvent1", "testEvent1", "Test Event"), + Event("testEvent2", "testEvent2", "Test Event 2")) private val task = Task( diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/TreasuryScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/TreasuryScreenTest.kt index 668b34d88..a3884e7ee 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/TreasuryScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/TreasuryScreenTest.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onChild import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -16,22 +17,25 @@ import com.github.se.assocify.model.database.AccountingSubCategoryAPI import com.github.se.assocify.model.database.BalanceAPI import com.github.se.assocify.model.database.BudgetAPI import com.github.se.assocify.model.database.ReceiptAPI +import com.github.se.assocify.model.database.UserAPI import com.github.se.assocify.model.entities.AccountingCategory import com.github.se.assocify.model.entities.AccountingSubCategory import com.github.se.assocify.model.entities.BalanceItem import com.github.se.assocify.model.entities.BudgetItem +import com.github.se.assocify.model.entities.PermissionRole import com.github.se.assocify.model.entities.Receipt +import com.github.se.assocify.model.entities.RoleType +import com.github.se.assocify.model.entities.Status import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.screens.treasury.TreasuryScreen import com.github.se.assocify.ui.screens.treasury.TreasuryViewModel -import com.github.se.assocify.ui.screens.treasury.accounting.AccountingViewModel -import com.github.se.assocify.ui.screens.treasury.receiptstab.ReceiptListViewModel import com.kaspersky.components.composesupport.config.withComposeSupport import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase import io.mockk.every import io.mockk.junit4.MockKRule import io.mockk.mockk +import java.time.LocalDate import org.junit.Before import org.junit.Rule import org.junit.Test @@ -53,6 +57,27 @@ class TreasuryScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCom AccountingSubCategory("2", "1", "OGJ", 2000, 1000), AccountingSubCategory("3", "1", "Subsonic", 100, 50), ) + val receiptList = + listOf( + Receipt( + "1", + "receipt1", + "desc", + LocalDate.of(2021, 1, 1), + 1000, + Status.Pending, + null, + "testUser"), + Receipt( + "2", + "receipt2", + "desc", + LocalDate.of(2021, 1, 1), + 1000, + Status.Pending, + null, + "testUser2")) + val mockAccountingCategoriesAPI: AccountingCategoryAPI = mockk() { every { getCategories(any(), any(), any()) } answers @@ -94,31 +119,41 @@ class TreasuryScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCom every { getAllReceipts(any(), any()) } answers { val onSuccessCallback = firstArg<(List) -> Unit>() - onSuccessCallback(listOf()) + onSuccessCallback(receiptList) } every { getUserReceipts(any(), any()) } answers { val onSuccessCallback = firstArg<(List) -> Unit>() - onSuccessCallback(listOf()) + onSuccessCallback(receiptList.filter { it.userId == "testUser" }) + } + } + + // treasuryViewModel.otherViewmodel to access accounting and receipt viewmodels :) + lateinit var treasuryViewModel: TreasuryViewModel + + val mockUserAPI: UserAPI = + mockk() { + every { getCurrentUserRole(any(), any()) } answers + { + val onSuccessCallback = firstArg<(PermissionRole) -> Unit>() + onSuccessCallback(PermissionRole("roleUid", "testAssociation", RoleType.TREASURY)) } } - var receiptListViewModel: ReceiptListViewModel = ReceiptListViewModel(navActions, mockReceiptAPI) - lateinit var accountingViewModel: AccountingViewModel @Before fun testSetup() { CurrentUser.userUid = "testUser" CurrentUser.associationUid = "testAssociation" - accountingViewModel = - AccountingViewModel( + treasuryViewModel = + TreasuryViewModel( + navActions, + mockReceiptAPI, mockAccountingCategoriesAPI, mockAccountingSubCategoryAPI, mockBalanceAPI, - mockBudgetAPI) - val viewModel = TreasuryViewModel(navActions, receiptListViewModel, accountingViewModel) - composeTestRule.setContent { - TreasuryScreen(navActions, accountingViewModel, receiptListViewModel, viewModel) - } + mockBudgetAPI, + mockUserAPI) + composeTestRule.setContent { TreasuryScreen(navActions, treasuryViewModel) } } @Test @@ -129,7 +164,7 @@ class TreasuryScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCom @Test fun navigate() { with(composeTestRule) { - onNodeWithTag("mainNavBarItem/home").performClick() + onNodeWithTag("mainNavBarItem/profile").performClick() assert(tabSelected) } } @@ -220,8 +255,47 @@ class TreasuryScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withCom secondArg<(Exception) -> Unit>().invoke(Exception("error")) } with(composeTestRule) { - receiptListViewModel.updateReceipts() + treasuryViewModel.receiptListViewModel.updateReceipts() onNodeWithTag("errorMessage").assertIsDisplayed().assertTextContains("Error loading receipts") } } + + @Test + fun receiptRefresh() { + every { mockReceiptAPI.updateCaches(any(), any()) } answers + { + secondArg<(Boolean, Exception) -> Unit>().invoke(true, Exception("error")) + } + with(composeTestRule) { + treasuryViewModel.receiptListViewModel.refreshReceipts() + onNodeWithText("Error refreshing receipts").assertIsDisplayed() + } + } + + @Test + fun refreshAccounting() { + every { mockBudgetAPI.updateBudgetCache(any(), any(), any()) } answers {} + every { mockBalanceAPI.updateBalanceCache(any(), any(), any()) } answers {} + every { mockAccountingCategoriesAPI.updateCategoryCache(any(), any(), any()) } answers {} + every { mockAccountingSubCategoryAPI.updateSubCategoryCache(any(), any(), any()) } answers + { + thirdArg<(Exception) -> Unit>().invoke(Exception("error")) + } + with(composeTestRule) { + treasuryViewModel.accountingViewModel.refreshAccounting() + onNodeWithText("Error refreshing accounting").assertIsDisplayed() + } + } + + @Test + fun testReceiptPermissions() { + with(composeTestRule) { + onNodeWithTag("receiptsTab").performClick() + onNodeWithText("My Receipts").assertIsDisplayed() + onNodeWithText("All Receipts").assertIsDisplayed() + assert( + treasuryViewModel.receiptListViewModel.uiState.value.userCurrentRole.type == + RoleType.TREASURY) + } + } } diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileEventsScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileEventsScreenTest.kt index 95c5e1f5a..a4165ae71 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileEventsScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileEventsScreenTest.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.se.assocify.model.CurrentUser import com.github.se.assocify.model.database.EventAPI @@ -20,7 +22,6 @@ import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase import io.mockk.every import io.mockk.mockk -import java.time.OffsetDateTime import org.junit.Before import org.junit.Rule import org.junit.Test @@ -34,10 +35,7 @@ class ProfileEventsScreenTest : private val navActions = mockk() private var goBack = false - private val events = - listOf( - Event("1", "event1", "desc1", OffsetDateTime.MIN, OffsetDateTime.MAX, "", ""), - Event("2", "event2", "desc2", OffsetDateTime.MIN, OffsetDateTime.MAX, "", "")) + private var events = listOf(Event("1", "event1", "desc1"), Event("2", "event2", "desc2")) private val mockEventAPI = mockk { @@ -46,6 +44,21 @@ class ProfileEventsScreenTest : val onSuccess = firstArg<(List) -> Unit>() onSuccess(events) } + every { addEvent(any(), any(), any()) } answers + { + events = events + firstArg() + secondArg<(String) -> Unit>().invoke(firstArg().uid) + } + every { updateEvent(any(), any(), any()) } answers + { + events = events.map { if (it.uid == firstArg().uid) firstArg() else it } + secondArg<() -> Unit>().invoke() + } + every { deleteEvent(any(), any(), any()) } answers + { + events = events.filter { it.uid != firstArg() } + secondArg<() -> Unit>().invoke() + } } @Before @@ -56,7 +69,7 @@ class ProfileEventsScreenTest : every { navActions.back() } answers { goBack = true } composeTestRule.setContent { - ProfileEventsScreen(navActions = navActions, ProfileEventsViewModel(mockEventAPI, navActions)) + ProfileEventsScreen(navActions = navActions, ProfileEventsViewModel(mockEventAPI)) } } @@ -67,7 +80,59 @@ class ProfileEventsScreenTest : onNodeWithTag("addEventButton").assertIsDisplayed().assertHasClickAction() events.forEach { onNodeWithText(it.name).assertIsDisplayed() } onAllNodesWithTag("editEventButton").assertCountEquals(events.size) - onAllNodesWithTag("deleteEventButton").assertCountEquals(events.size) + for (i in events.indices) onNodeWithTag("deleteEventButton-$i").assertIsDisplayed() + } + } + + @Test + fun addEvent() { + with(composeTestRule) { + onNodeWithTag("addEventButton").performClick() + onNodeWithTag("updateEventDialog").assertIsDisplayed() + onNodeWithTag("editName").assertIsDisplayed().performTextInput("event3") + onNodeWithTag("editDescription").assertIsDisplayed().performTextInput("desc3") + onNodeWithTag("confirmButton").performClick() + onNodeWithText("event3").assertIsDisplayed() + onNodeWithText("desc3").assertIsDisplayed() + } + } + + @Test + fun modifyEvent() { + with(composeTestRule) { + onAllNodesWithTag("editEventButton").apply { + fetchSemanticsNodes().forEachIndexed { i, _ -> + get(i).assertIsDisplayed().assertHasClickAction() + if (i == events.size - 1) get(i).performClick() + } + } + onNodeWithTag("updateEventDialog").assertIsDisplayed() + onNodeWithTag("editName").performTextInput("changing ") + onNodeWithTag("editDescription").assertIsDisplayed().performTextClearance() + onNodeWithTag("confirmButton").performClick() + onNodeWithText("changing event2").assertIsDisplayed() + onNodeWithText("-").assertIsDisplayed() + } + } + + @Test + fun deleteEvent() { + with(composeTestRule) { + for (i in events.indices) { + onNodeWithTag("deleteEventButton-$i").assertIsDisplayed().assertHasClickAction() + if (i == events.size - 1) { + val eventName = events[i].name + onNodeWithTag("deleteEventButton-$i").performClick() + onNodeWithTag("deleteEventDialog").assertIsDisplayed() + onNodeWithTag("cancelButton").performClick() + onNodeWithText(events[i].name).assertIsDisplayed() + onNodeWithTag("deleteEventButton-$i").performClick() + onNodeWithText("Are you sure you want to delete the event ${events[i].name}?") + .assertIsDisplayed() + onNodeWithTag("confirmButton").performClick() + onNodeWithText(eventName).assertDoesNotExist() + } + } } } diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileScreenTest.kt index fc6f582c6..4c932584c 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileScreenTest.kt @@ -305,6 +305,20 @@ class ProfileScreenTest : TestCase(kaspressoBuilder = Kaspresso.Builder.withComp } } + @Test + fun refreshProfile() { + every { mockAssocAPI.updateCache(any(), any()) } answers {} + every { mockUserAPI.updateUserCache(any(), any()) } answers + { + val onErrorCallback = secondArg<(Exception) -> Unit>() + onErrorCallback(Exception("error")) + } + with(composeTestRule) { + mViewmodel.refreshProfile() + onNodeWithText("Could not refresh").assertIsDisplayed() + } + } + @Test fun openProfileSheet() { with(composeTestRule) { diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileTreasuryTagsScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileTreasuryTagsScreenTest.kt index 1df637c5f..9decaa469 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileTreasuryTagsScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileTreasuryTagsScreenTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.se.assocify.model.CurrentUser import com.github.se.assocify.model.database.AccountingCategoryAPI @@ -42,6 +43,16 @@ class ProfileTreasuryTagsScreenTest : val onSuccess = secondArg<(List) -> Unit>() onSuccess(accCats) } + every { addCategory(any(), any(), any(), any()) } answers + { + val onSuccess = thirdArg<() -> Unit>() + onSuccess() + } + every { updateCategory(any(), any(), any(), any()) } answers + { + val onSuccess = thirdArg<() -> Unit>() + onSuccess() + } } @Before @@ -64,7 +75,7 @@ class ProfileTreasuryTagsScreenTest : onNodeWithTag("TreasuryTags Screen").assertIsDisplayed() onNodeWithTag("addTagButton").assertIsDisplayed().assertHasClickAction() accCats.forEach { onNodeWithText(it.name).assertIsDisplayed() } - onAllNodesWithTag("editTagButton").assertCountEquals(accCats.size) + accCats.forEach { onNodeWithTag(it.uid).assertIsDisplayed() } onAllNodesWithTag("deleteTagButton").assertCountEquals(accCats.size) } } @@ -76,4 +87,25 @@ class ProfileTreasuryTagsScreenTest : assert(goBack) } } + + @Test + fun createTag() { + with(composeTestRule) { + onNodeWithTag("addTagButton").performClick() + onNodeWithTag("nameField").performClick() + onNodeWithTag("nameField").performTextInput("testTag") + onNodeWithTag("saveButton").performClick() + onNodeWithText("testTag").assertIsDisplayed() + } + } + + @Test + fun editTag() { + with(composeTestRule) { + onNodeWithTag("1").performClick() + onNodeWithTag("nameField").performTextInput("cat2") + onNodeWithTag("saveButton").performClick() + onNodeWithText("cat2cat1").assertIsDisplayed() + } + } } diff --git a/app/src/main/java/com/github/se/assocify/AssocifyApp.kt b/app/src/main/java/com/github/se/assocify/AssocifyApp.kt index 0ba54904d..df9f74ffc 100644 --- a/app/src/main/java/com/github/se/assocify/AssocifyApp.kt +++ b/app/src/main/java/com/github/se/assocify/AssocifyApp.kt @@ -46,7 +46,7 @@ fun AssocifyApp(loginSaver: LoginSave) { val firstDest = if (CurrentUser.userUid != null && CurrentUser.associationUid != null) { - Destination.Home.route + Destination.Treasury.route } else { Destination.Login.route } diff --git a/app/src/main/java/com/github/se/assocify/model/database/BalanceAPI.kt b/app/src/main/java/com/github/se/assocify/model/database/BalanceAPI.kt index 12d62333e..dd8ccb2ae 100644 --- a/app/src/main/java/com/github/se/assocify/model/database/BalanceAPI.kt +++ b/app/src/main/java/com/github/se/assocify/model/database/BalanceAPI.kt @@ -60,7 +60,7 @@ class BalanceAPI(private val db: SupabaseClient) : SupabaseApi() { fun addBalance( associationUID: String, categoryUID: String, - receiptUID: String, + receiptUID: String?, balanceItem: BalanceItem, onSuccess: () -> Unit, onFailure: (Exception) -> Unit @@ -80,7 +80,7 @@ class BalanceAPI(private val db: SupabaseClient) : SupabaseApi() { fun updateBalance( associationUID: String, balanceItem: BalanceItem, - receiptUID: String, + receiptUID: String?, categoryUID: String, onSuccess: () -> Unit, onFailure: (Exception) -> Unit @@ -114,7 +114,7 @@ class BalanceAPI(private val db: SupabaseClient) : SupabaseApi() { @SerialName("uid") val uid: String, @SerialName("name") val nameItem: String, @SerialName("association_uid") val associationUID: String, - @SerialName("receipt_uid") val receiptUID: String, + @SerialName("receipt_uid") val receiptUID: String?, @SerialName("subcategory_uid") val subcategoryUID: String, @SerialName("amount") val amount: Int, // unsigned: can be positive or negative @SerialName("tva") val tva: Float, @@ -142,7 +142,7 @@ class BalanceAPI(private val db: SupabaseClient) : SupabaseApi() { fun fromBalanceItem( balanceItem: BalanceItem, associationUID: String, - receiptUID: String, + receiptUID: String?, categoryUID: String ) = SupabaseBalanceItem( diff --git a/app/src/main/java/com/github/se/assocify/model/database/EventAPI.kt b/app/src/main/java/com/github/se/assocify/model/database/EventAPI.kt index 9a355f651..19df9ae8d 100644 --- a/app/src/main/java/com/github/se/assocify/model/database/EventAPI.kt +++ b/app/src/main/java/com/github/se/assocify/model/database/EventAPI.kt @@ -4,7 +4,6 @@ import com.github.se.assocify.model.CurrentUser import com.github.se.assocify.model.entities.Event import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.postgrest.postgrest -import java.time.OffsetDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -54,10 +53,6 @@ class EventAPI(db: SupabaseClient) : SupabaseApi() { uid = event.uid, name = event.name, description = event.description, - startDate = event.startDate.toString(), - endDate = event.endDate.toString(), - guestsOrArtists = event.guestsOrArtists, - location = event.location, associationUID = CurrentUser.associationUid)) eventCache = eventCache?.plus(event) @@ -112,10 +107,6 @@ class EventAPI(db: SupabaseClient) : SupabaseApi() { uid = event.uid, name = event.name, description = event.description, - startDate = event.startDate, - endDate = event.endDate, - guestsOrArtists = event.guestsOrArtists, - location = event.location, onSuccess = onSuccess, onFailure = onFailure) } @@ -126,10 +117,6 @@ class EventAPI(db: SupabaseClient) : SupabaseApi() { * @param uid the id of the event to update * @param name the name of the event * @param description the description of the event - * @param startDate the start date of the event - * @param endDate the end date of the event - * @param guestsOrArtists the guests or artists of the event - * @param location the location of the event * @param onSuccess called on success (by default does nothing) * @param onFailure called on failure */ @@ -137,10 +124,6 @@ class EventAPI(db: SupabaseClient) : SupabaseApi() { uid: String, name: String, description: String, - startDate: OffsetDateTime, - endDate: OffsetDateTime, - guestsOrArtists: String, - location: String, onSuccess: () -> Unit = {}, onFailure: (Exception) -> Unit ) { @@ -148,10 +131,6 @@ class EventAPI(db: SupabaseClient) : SupabaseApi() { postgrest.from(collectionName).update({ SupabaseEvent::name setTo name SupabaseEvent::description setTo description - SupabaseEvent::startDate setTo startDate.toString() - SupabaseEvent::endDate setTo endDate.toString() - SupabaseEvent::guestsOrArtists setTo guestsOrArtists - SupabaseEvent::location setTo location }) { filter { Event::uid eq uid } } @@ -159,14 +138,7 @@ class EventAPI(db: SupabaseClient) : SupabaseApi() { eventCache = eventCache?.map { if (it.uid == uid) { - Event( - uid = uid, - name = name, - description = description, - startDate = startDate, - endDate = endDate, - guestsOrArtists = guestsOrArtists, - location = location) + Event(uid = uid, name = name, description = description) } else { it } @@ -197,20 +169,8 @@ class EventAPI(db: SupabaseClient) : SupabaseApi() { val uid: String, val name: String, val description: String, - @SerialName("start_date") val startDate: String, - @SerialName("end_date") val endDate: String, - @SerialName("guests_or_artists") val guestsOrArtists: String, - val location: String, @SerialName("association_uid") val associationUID: String? ) { - fun toEvent() = - Event( - uid = uid, - name = name, - description = description, - startDate = OffsetDateTime.parse(startDate), - endDate = OffsetDateTime.parse(endDate), - guestsOrArtists = guestsOrArtists, - location = location) + fun toEvent() = Event(uid = uid, name = name, description = description) } } diff --git a/app/src/main/java/com/github/se/assocify/model/entities/BalanceItem.kt b/app/src/main/java/com/github/se/assocify/model/entities/BalanceItem.kt index 83be55d0b..2464afaac 100644 --- a/app/src/main/java/com/github/se/assocify/model/entities/BalanceItem.kt +++ b/app/src/main/java/com/github/se/assocify/model/entities/BalanceItem.kt @@ -20,7 +20,7 @@ data class BalanceItem( val uid: String, val nameItem: String, val subcategoryUID: String, - val receiptUID: String, + val receiptUID: String?, val amount: Int, // unsigned: can be positive or negative val tva: TVA, val description: String, diff --git a/app/src/main/java/com/github/se/assocify/model/entities/Event.kt b/app/src/main/java/com/github/se/assocify/model/entities/Event.kt index b8a5daa02..1182eaf0b 100644 --- a/app/src/main/java/com/github/se/assocify/model/entities/Event.kt +++ b/app/src/main/java/com/github/se/assocify/model/entities/Event.kt @@ -1,23 +1,14 @@ package com.github.se.assocify.model.entities -import java.time.OffsetDateTime import java.util.UUID /** * @param uid unique identifier of the event * @param name name of the event * @param description description of the event - * @param startDate start date of the event - * @param endDate end date of the event - * @param guestsOrArtists guests or artists of the event - * @param location location of the event */ data class Event( val uid: String = UUID.randomUUID().toString(), val name: String, val description: String, - val startDate: OffsetDateTime, - val endDate: OffsetDateTime, - val guestsOrArtists: String, - val location: String ) diff --git a/app/src/main/java/com/github/se/assocify/navigation/Destination.kt b/app/src/main/java/com/github/se/assocify/navigation/Destination.kt index 0ab778d9d..e2b6ea1da 100644 --- a/app/src/main/java/com/github/se/assocify/navigation/Destination.kt +++ b/app/src/main/java/com/github/se/assocify/navigation/Destination.kt @@ -9,15 +9,12 @@ sealed class Destination( @StringRes val labelId: Int? = null, @DrawableRes val iconId: Int? = null ) { - data object Home : Destination("home", R.string.home_tab_label, R.drawable.home_tab_icon) data object Treasury : Destination("treasury", R.string.treasury_tab_label, R.drawable.treasury_tab_icon) data object Event : Destination("event", R.string.event_tab_label, R.drawable.event_tab_icon) - data object Chat : Destination("chat", R.string.chat_tab_label, R.drawable.chat_tab_icon) - data object Profile : Destination("profile", R.string.profile_tab_label, R.drawable.profile_tab_icon) @@ -50,10 +47,4 @@ sealed class Destination( data class EditTask(val taskUid: String) : Destination("event/task/$taskUid") } -val MAIN_TABS_LIST = - listOf( - Destination.Home, - Destination.Treasury, - Destination.Event, - Destination.Chat, - Destination.Profile) +val MAIN_TABS_LIST = listOf(Destination.Treasury, Destination.Event, Destination.Profile) diff --git a/app/src/main/java/com/github/se/assocify/navigation/NavigationActions.kt b/app/src/main/java/com/github/se/assocify/navigation/NavigationActions.kt index 731be56f7..c3b785d48 100644 --- a/app/src/main/java/com/github/se/assocify/navigation/NavigationActions.kt +++ b/app/src/main/java/com/github/se/assocify/navigation/NavigationActions.kt @@ -29,7 +29,7 @@ class NavigationActions( fun onLogin(userHasMembership: Boolean) { if (userHasMembership) { loginSaver.saveUserInfo() - navController.navigate(Destination.Home.route) { + navController.navigate(Destination.Treasury.route) { popUpTo(navController.graph.id) { inclusive = true } } } else { diff --git a/app/src/main/java/com/github/se/assocify/navigation/NavigationGraph.kt b/app/src/main/java/com/github/se/assocify/navigation/NavigationGraph.kt index 0cc735377..5cc8d665a 100644 --- a/app/src/main/java/com/github/se/assocify/navigation/NavigationGraph.kt +++ b/app/src/main/java/com/github/se/assocify/navigation/NavigationGraph.kt @@ -10,10 +10,8 @@ import com.github.se.assocify.model.database.EventAPI import com.github.se.assocify.model.database.ReceiptAPI import com.github.se.assocify.model.database.TaskAPI import com.github.se.assocify.model.database.UserAPI -import com.github.se.assocify.ui.screens.chat.chatGraph import com.github.se.assocify.ui.screens.createAssociation.createAssociationGraph import com.github.se.assocify.ui.screens.event.eventGraph -import com.github.se.assocify.ui.screens.home.homeGraph import com.github.se.assocify.ui.screens.login.loginGraph import com.github.se.assocify.ui.screens.profile.profileGraph import com.github.se.assocify.ui.screens.selectAssociation.selectAssociationGraph @@ -31,16 +29,15 @@ fun NavGraphBuilder.mainNavGraph( accountingCategoriesAPI: AccountingCategoryAPI, accountingSubCategoryAPI: AccountingSubCategoryAPI ) { - homeGraph(navActions) treasuryGraph( navActions, budgetAPI, balanceAPI, receiptsAPI, accountingCategoriesAPI, - accountingSubCategoryAPI) + accountingSubCategoryAPI, + userAPI) eventGraph(navActions, eventAPI, taskAPI) - chatGraph(navActions) profileGraph(navActions, userAPI, associationAPI, accountingCategoriesAPI, eventAPI) loginGraph(navActions, userAPI) selectAssociationGraph(navActions, userAPI, associationAPI) diff --git a/app/src/main/java/com/github/se/assocify/ui/composables/PullDownRefreshBox.kt b/app/src/main/java/com/github/se/assocify/ui/composables/PullDownRefreshBox.kt new file mode 100644 index 000000000..f9a3fb828 --- /dev/null +++ b/app/src/main/java/com/github/se/assocify/ui/composables/PullDownRefreshBox.kt @@ -0,0 +1,52 @@ +package com.github.se.assocify.ui.composables + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A container to make a scrollable content pullable to refresh. + * + * This box is meant to be used as a wrapper around a vertically scrolling element, such as a Column + * or LazyColumn. It will allow the user to pull down on the content to refresh it. The box will + * automatically show a loading indicator when the content is refreshing. + * + * The box will take up the entire size of its parent (typically a Scaffold), and will apply the + * padding values to the content inside the box. + * + * NOTE: Pass the scaffold padding values to the box itself, NOT the content inside the box. + * + * @param refreshing Whether the content is currently refreshing. + * @param onRefresh The callback to call when the user pulls down to refresh. + * @param paddingValues The padding values to apply to the container. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun PullDownRefreshBox( + refreshing: Boolean, + onRefresh: () -> Unit, + paddingValues: PaddingValues? = null, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {}, +) { + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh) + + Box( + modifier = + modifier + .padding(paddingValues ?: PaddingValues(0.dp)) + .fillMaxSize() + .pullRefresh(pullRefreshState)) { + content() + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } +} diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/chat/ChatGraph.kt b/app/src/main/java/com/github/se/assocify/ui/screens/chat/ChatGraph.kt deleted file mode 100644 index f5baa71af..000000000 --- a/app/src/main/java/com/github/se/assocify/ui/screens/chat/ChatGraph.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.se.assocify.ui.screens.chat - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.github.se.assocify.navigation.Destination -import com.github.se.assocify.navigation.NavigationActions - -fun NavGraphBuilder.chatGraph(navigationActions: NavigationActions) { - composable( - route = Destination.Chat.route, - ) { - ChatScreen(navigationActions) - } -} diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/chat/ChatScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/chat/ChatScreen.kt deleted file mode 100644 index c79280598..000000000 --- a/app/src/main/java/com/github/se/assocify/ui/screens/chat/ChatScreen.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.se.assocify.ui.screens.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import com.github.se.assocify.navigation.Destination -import com.github.se.assocify.navigation.MAIN_TABS_LIST -import com.github.se.assocify.navigation.NavigationActions -import com.github.se.assocify.ui.composables.MainNavigationBar - -@Composable -fun ChatScreen(navActions: NavigationActions) { - Scaffold( - modifier = Modifier.testTag("chatScreen"), - bottomBar = { - MainNavigationBar( - onTabSelect = { navActions.navigateToMainTab(it) }, - tabList = MAIN_TABS_LIST, - selectedTab = Destination.Chat) - }) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxSize()) { - Text(modifier = Modifier.padding(it), text = "Chat Screen") - } - } -} diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/event/EventScreenViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/event/EventScreenViewModel.kt index 6716db913..1f4c12976 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/event/EventScreenViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/event/EventScreenViewModel.kt @@ -10,6 +10,7 @@ import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.screens.event.maptab.EventMapViewModel import com.github.se.assocify.ui.screens.event.scheduletab.EventScheduleViewModel import com.github.se.assocify.ui.screens.event.tasktab.EventTaskViewModel +import com.github.se.assocify.ui.util.SnackbarSystem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -28,13 +29,16 @@ class EventScreenViewModel( private var eventAPI: EventAPI, ) : ViewModel() { - val taskListViewModel: EventTaskViewModel = EventTaskViewModel(taskAPI) { showSnackbar(it) } - val mapViewModel: EventMapViewModel = EventMapViewModel(taskAPI) - val scheduleViewModel: EventScheduleViewModel = EventScheduleViewModel(navActions, taskAPI) - private val _uiState: MutableStateFlow = MutableStateFlow(EventScreenState()) val uiState: StateFlow = _uiState + val snackbarSystem: SnackbarSystem = SnackbarSystem(_uiState.value.snackbarHostState) + + val taskListViewModel: EventTaskViewModel = EventTaskViewModel(taskAPI, snackbarSystem) + val mapViewModel: EventMapViewModel = EventMapViewModel(taskAPI) + val scheduleViewModel: EventScheduleViewModel = + EventScheduleViewModel(navActions, taskAPI, snackbarSystem) + init { fetchEvents() } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleScreen.kt index 4913db09e..fb70210aa 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleScreen.kt @@ -39,6 +39,7 @@ import com.github.se.assocify.model.entities.Task import com.github.se.assocify.ui.composables.CenteredCircularIndicator import com.github.se.assocify.ui.composables.DatePickerWithDialog import com.github.se.assocify.ui.composables.ErrorMessage +import com.github.se.assocify.ui.composables.PullDownRefreshBox import com.github.se.assocify.ui.util.DateTimeUtil import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -64,15 +65,18 @@ fun EventScheduleScreen( } if (state.error != null) { - ErrorMessage(errorMessage = state.error) { viewModel.fetchTasks() } + ErrorMessage(errorMessage = state.error) { viewModel.loadSchedule() } return } - Column { - DateSwitcher(viewModel) - Row(modifier = Modifier.verticalScroll(scrollState)) { - ScheduleSidebar(hourHeight = hourHeight) - ScheduleContent(hourHeight = hourHeight, tasks = state.currentDayTasks, viewModel = viewModel) + PullDownRefreshBox(refreshing = state.refresh, onRefresh = { viewModel.refreshSchedule() }) { + Column { + DateSwitcher(viewModel) + Row(modifier = Modifier.verticalScroll(scrollState)) { + ScheduleSidebar(hourHeight = hourHeight) + ScheduleContent( + hourHeight = hourHeight, tasks = state.currentDayTasks, viewModel = viewModel) + } } } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleViewModel.kt index 78a6c950c..c46755fce 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleViewModel.kt @@ -7,6 +7,8 @@ import com.github.se.assocify.navigation.Destination import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.util.DateTimeUtil import com.github.se.assocify.ui.util.DateUtil +import com.github.se.assocify.ui.util.SnackbarSystem +import com.github.se.assocify.ui.util.SyncSystem import java.time.LocalDate import java.time.LocalTime import kotlinx.coroutines.flow.MutableStateFlow @@ -16,28 +18,51 @@ import kotlinx.coroutines.flow.StateFlow class EventScheduleViewModel( private val navActions: NavigationActions, private val taskAPI: TaskAPI, + private val snackbarSystem: SnackbarSystem, ) { private val _uiState: MutableStateFlow = MutableStateFlow(ScheduleState()) val uiState: StateFlow = _uiState + private val loadSystem = + SyncSystem( + { _uiState.value = _uiState.value.copy(loading = false, refresh = false, error = null) }, + { _uiState.value = _uiState.value.copy(loading = false, refresh = false, error = it) }) + + private val refreshSystem = + SyncSystem( + { loadSchedule() }, + { + _uiState.value = _uiState.value.copy(refresh = false) + snackbarSystem.showSnackbar(it) + }) + /** Initializes the ViewModel by setting the current date and fetching the tasks. */ init { changeDate(LocalDate.now()) - fetchTasks() + loadSchedule() } /** * Fetches the tasks from the database and updates the UI state. If the tasks could not be loaded, * an error message is displayed. If the tasks are loading, a loading indicator is displayed. */ - fun fetchTasks() { + fun loadSchedule() { + if (!loadSystem.start(1)) return _uiState.value = _uiState.value.copy(loading = true, error = null) taskAPI.getTasks( { tasks -> filterTasks(tasks) - _uiState.value = _uiState.value.copy(tasks = tasks, loading = false, error = null) + _uiState.value = _uiState.value.copy(tasks = tasks) + loadSystem.end() }, - { _uiState.value = _uiState.value.copy(loading = false, error = "Error loading tasks") }) + { loadSystem.end("Error loading tasks") }) + } + + fun refreshSchedule() { + if (!refreshSystem.start(1)) return + _uiState.value = _uiState.value.copy(refresh = true) + taskAPI.updateTaskCache( + { refreshSystem.end() }, { refreshSystem.end("Could not refresh tasks") }) } /** @@ -292,6 +317,7 @@ class EventScheduleViewModel( data class ScheduleState( val loading: Boolean = false, val error: String? = null, + val refresh: Boolean = false, val tasks: List = emptyList(), val currentDate: LocalDate = LocalDate.now(), val currentDayTasks: List = emptyList(), diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/event/tasktab/EventTaskScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/event/tasktab/EventTaskScreen.kt index 2cf454d7f..98feab9c5 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/event/tasktab/EventTaskScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/event/tasktab/EventTaskScreen.kt @@ -20,6 +20,7 @@ import com.github.se.assocify.navigation.Destination import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.composables.CenteredCircularIndicator import com.github.se.assocify.ui.composables.ErrorMessage +import com.github.se.assocify.ui.composables.PullDownRefreshBox import com.github.se.assocify.ui.screens.event.EventScreenViewModel import com.github.se.assocify.ui.util.DateTimeUtil @@ -53,42 +54,48 @@ fun EventTaskScreen( mainState.selectedEvents.any { ev -> ev.uid == t.eventUid } } - if (mainState.selectedEvents.isEmpty()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxSize()) { - Text("No events selected") - } - } else if (visibleTasks.isEmpty()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxSize()) { - Text("No tasks found for selected events") - } - } else { - LazyColumn(modifier = Modifier.fillMaxWidth()) { - visibleTasks.forEach { - item { - ListItem( - modifier = - Modifier.testTag("TaskItem").clickable { - navActions.navigateTo(Destination.EditTask(it.uid)) - }, - headlineContent = { Text(it.title) }, - supportingContent = { Text(it.category) }, - trailingContent = { - Checkbox( - modifier = Modifier.testTag("TaskCheckbox"), - checked = it.isCompleted, - onCheckedChange = { checked -> eventTaskViewModel.checkTask(it, checked) }, - ) - }, - overlineContent = { Text(DateTimeUtil.formatDateTime(it.startTime)) }) - HorizontalDivider() + PullDownRefreshBox( + refreshing = state.refresh, onRefresh = { eventTaskViewModel.refreshTasks() }) { + if (mainState.selectedEvents.isEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize()) { + Text("No events selected") + } + } else if (visibleTasks.isEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize()) { + Text("No tasks found for selected events") + } + } else { + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + visibleTasks.forEach { + item { + ListItem( + modifier = + Modifier.testTag("TaskItem").clickable { + navActions.navigateTo(Destination.EditTask(it.uid)) + }, + headlineContent = { Text(it.title) }, + supportingContent = { Text(it.category) }, + trailingContent = { + Checkbox( + modifier = Modifier.testTag("TaskCheckbox"), + checked = it.isCompleted, + onCheckedChange = { checked -> + eventTaskViewModel.checkTask(it, checked) + }, + ) + }, + overlineContent = { Text(DateTimeUtil.formatDateTime(it.startTime)) }) + HorizontalDivider() + } + } + } } } - } - } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/event/tasktab/EventTaskViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/event/tasktab/EventTaskViewModel.kt index 8527d3c67..85d5c57d9 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/event/tasktab/EventTaskViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/event/tasktab/EventTaskViewModel.kt @@ -4,13 +4,28 @@ import androidx.lifecycle.ViewModel import com.github.se.assocify.model.database.TaskAPI import com.github.se.assocify.model.entities.Event import com.github.se.assocify.model.entities.Task +import com.github.se.assocify.ui.util.SnackbarSystem +import com.github.se.assocify.ui.util.SyncSystem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class EventTaskViewModel(val db: TaskAPI, val showSnackbar: (String) -> Unit) : ViewModel() { +class EventTaskViewModel(val db: TaskAPI, val snackbarSystem: SnackbarSystem) : ViewModel() { private val _uiState = MutableStateFlow(EventTaskState()) val uiState: StateFlow + private val loadSystem = + SyncSystem( + { _uiState.value = _uiState.value.copy(loading = false, refresh = false, error = null) }, + { _uiState.value = _uiState.value.copy(loading = false, refresh = false, error = it) }) + + private val refreshSystem = + SyncSystem( + { updateTasks() }, + { + _uiState.value = _uiState.value.copy(refresh = false) + snackbarSystem.showSnackbar(it) + }) + init { uiState = _uiState updateTasks() @@ -18,14 +33,23 @@ class EventTaskViewModel(val db: TaskAPI, val showSnackbar: (String) -> Unit) : /** Updates the list of tasks in the UI. */ fun updateTasks() { + if (!loadSystem.start(1)) return + _uiState.value = _uiState.value.copy(loading = true, error = null) + db.getTasks( { tasks -> - _uiState.value = _uiState.value.copy(loading = false, error = null) _uiState.value = _uiState.value.copy(tasks = tasks) filterTasks() + loadSystem.end() }, - { _uiState.value = _uiState.value.copy(loading = false, error = "Error loading tasks") }) + { loadSystem.end("Error loading tasks") }) + } + + fun refreshTasks() { + if (!refreshSystem.start(1)) return + _uiState.value = _uiState.value.copy(refresh = true) + db.updateTaskCache({ refreshSystem.end() }, { refreshSystem.end("Could not refresh tasks") }) } /** @@ -46,7 +70,7 @@ class EventTaskViewModel(val db: TaskAPI, val showSnackbar: (String) -> Unit) : }) filterTasks() }, - { showSnackbar("Couldn't update task state") }) + { snackbarSystem.showSnackbar("Couldn't update task state") }) } /** @@ -92,6 +116,7 @@ class EventTaskViewModel(val db: TaskAPI, val showSnackbar: (String) -> Unit) : data class EventTaskState( val loading: Boolean = false, val error: String? = null, + val refresh: Boolean = false, val tasks: List = emptyList(), val filteredTasks: List = emptyList(), val filteredEvents: List = emptyList(), diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/home/HomeGraph.kt b/app/src/main/java/com/github/se/assocify/ui/screens/home/HomeGraph.kt deleted file mode 100644 index ff5c18af1..000000000 --- a/app/src/main/java/com/github/se/assocify/ui/screens/home/HomeGraph.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.se.assocify.ui.screens.home - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.github.se.assocify.navigation.Destination -import com.github.se.assocify.navigation.NavigationActions - -fun NavGraphBuilder.homeGraph(navigationActions: NavigationActions) { - composable( - route = Destination.Home.route, - ) { - HomeScreen(navigationActions) - } -} diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/home/HomeScreen.kt deleted file mode 100644 index 6da2c80ff..000000000 --- a/app/src/main/java/com/github/se/assocify/ui/screens/home/HomeScreen.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.se.assocify.ui.screens.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import com.github.se.assocify.navigation.Destination -import com.github.se.assocify.navigation.MAIN_TABS_LIST -import com.github.se.assocify.navigation.NavigationActions -import com.github.se.assocify.ui.composables.MainNavigationBar - -@Composable -fun HomeScreen(navActions: NavigationActions) { - Scaffold( - modifier = Modifier.testTag("homeScreen"), - bottomBar = { - MainNavigationBar( - onTabSelect = { navActions.navigateToMainTab(it) }, - tabList = MAIN_TABS_LIST, - selectedTab = Destination.Home) - }) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxSize()) { - Text(modifier = Modifier.padding(it), text = "Home Screen") - } - } -} diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileScreen.kt index 950633942..fc31f1f0a 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowRight import androidx.compose.material.icons.automirrored.filled.Logout @@ -57,6 +58,7 @@ import com.github.se.assocify.ui.composables.ErrorMessage import com.github.se.assocify.ui.composables.MainNavigationBar import com.github.se.assocify.ui.composables.MainTopBar import com.github.se.assocify.ui.composables.PhotoSelectionSheet +import com.github.se.assocify.ui.composables.PullDownRefreshBox /** * Profile screen that displays the user's information, a way to change your current association and @@ -66,6 +68,7 @@ import com.github.se.assocify.ui.composables.PhotoSelectionSheet * @param navActions: NavigationActions object that contains the navigation actions. * @param viewmodel: ProfileViewModel object that contains the logic of the profile screen. */ +@OptIn(ExperimentalMaterialApi::class) @Composable fun ProfileScreen(navActions: NavigationActions, viewmodel: ProfileViewModel) { val state by viewmodel.uiState.collectAsState() @@ -87,7 +90,7 @@ fun ProfileScreen(navActions: NavigationActions, viewmodel: ProfileViewModel) { Snackbar(snackbarData = snackbarData, modifier = Modifier.testTag("snackbar")) }) }) { innerPadding -> - if (state.loading) { + if (state.loading && !state.refresh) { CenteredCircularIndicator() return@Scaffold } @@ -105,139 +108,155 @@ fun ProfileScreen(navActions: NavigationActions, viewmodel: ProfileViewModel) { return@Scaffold } - Column( - modifier = - Modifier.padding(innerPadding).verticalScroll(rememberScrollState()).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start)) { + PullDownRefreshBox( + refreshing = state.refresh, + onRefresh = { viewmodel.refreshProfile() }, + paddingValues = innerPadding) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start)) { - // profile picture - if (state.profileImageURI != null) { - AsyncImage( - modifier = - Modifier.size(80.dp) - .clip(CircleShape) // Clip the image to a circle shape - .aspectRatio(1f) - .clickable { viewmodel.controlBottomSheet(true) } - .testTag("profilePicture"), - model = state.profileImageURI, - contentDescription = "profile picture", - contentScale = ContentScale.Crop) - } else { - IconButton( - modifier = Modifier.testTag("default profile icon").size(80.dp), - onClick = { viewmodel.controlBottomSheet(true) }) { - Icon( - modifier = Modifier.fillMaxSize(), - imageVector = Icons.Outlined.AccountCircle, - contentDescription = "default profile icon") + // profile picture + if (state.profileImageURI != null) { + AsyncImage( + modifier = + Modifier.size(80.dp) + .clip(CircleShape) // Clip the image to a circle shape + .aspectRatio(1f) + .clickable { viewmodel.controlBottomSheet(true) } + .testTag("profilePicture"), + model = state.profileImageURI, + contentDescription = "profile picture", + contentScale = ContentScale.Crop) + } else { + IconButton( + modifier = Modifier.testTag("default profile icon").size(80.dp), + onClick = { viewmodel.controlBottomSheet(true) }) { + Icon( + modifier = Modifier.fillMaxSize(), + imageVector = Icons.Outlined.AccountCircle, + contentDescription = "default profile icon") + } } - } - // personal information : name and role (depends on current association) - Column(modifier = Modifier.testTag("profileInfos").weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - state.myName, - modifier = Modifier.testTag("profileName").weight(1f), - style = MaterialTheme.typography.headlineSmall) + // personal information : name and role (depends on current association) + Column(modifier = Modifier.testTag("profileInfos").weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + state.myName, + modifier = Modifier.testTag("profileName").weight(1f), + style = MaterialTheme.typography.headlineSmall) - // edit name button - IconButton( - onClick = { viewmodel.controlNameEdit(true) }, - modifier = Modifier.testTag("editProfile")) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = "Edit Profile Icon") + // edit name button + IconButton( + onClick = { viewmodel.controlNameEdit(true) }, + modifier = Modifier.testTag("editProfile")) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = "Edit Profile Icon") + } } - } - Text(state.currentRole.type.name, modifier = Modifier.testTag("profileRole")) - } - } + Text( + state.currentRole.type.name, + modifier = Modifier.testTag("profileRole")) + } + } - // Change_association dropdown - DropdownWithSetOptions( - options = state.myAssociations, - selectedOption = - DropdownOption( - state.selectedAssociation.name, - state.selectedAssociation.uid, - state.selectedAssociation.leadIcon), - opened = state.openAssociationDropdown, - onOpenedChange = { viewmodel.controlAssociationDropdown(it) }, - onSelectOption = { viewmodel.setAssociation(it) }, - modifier = - Modifier.testTag("associationDropdown").align(Alignment.CenterHorizontally)) + // Change_association dropdown + DropdownWithSetOptions( + options = state.myAssociations, + selectedOption = + DropdownOption( + state.selectedAssociation.name, + state.selectedAssociation.uid, + state.selectedAssociation.leadIcon), + opened = state.openAssociationDropdown, + onOpenedChange = { viewmodel.controlAssociationDropdown(it) }, + onSelectOption = { viewmodel.setAssociation(it) }, + modifier = + Modifier.testTag("associationDropdown") + .align(Alignment.CenterHorizontally)) - Text(text = "Settings", style = MaterialTheme.typography.titleMedium) + Text(text = "Settings", style = MaterialTheme.typography.titleMedium) - Column( - modifier = - Modifier.fillMaxWidth() - .testTag("settingsList") - .clip(RoundedCornerShape(12.dp))) { - MySettings.entries.forEach { setting -> - ListItem( - leadingContent = { - Icon( - imageVector = setting.getIcon(), - contentDescription = "${setting.name} icon") - }, - headlineContent = { Text(text = setting.name) }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowRight, - contentDescription = "Go to ${setting.name} settings") - }, - colors = - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.primaryContainer), - modifier = - Modifier.testTag(setting.name).clickable { - navActions.navigateTo(setting.getDestination()) - }) - } - } + Column( + modifier = + Modifier.fillMaxWidth() + .testTag("settingsList") + .clip(RoundedCornerShape(12.dp))) { + MySettings.entries.forEach { setting -> + ListItem( + leadingContent = { + Icon( + imageVector = setting.getIcon(), + contentDescription = "${setting.name} icon") + }, + headlineContent = { Text(text = setting.name) }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowRight, + contentDescription = "Go to ${setting.name} settings") + }, + colors = + ListItemDefaults.colors( + containerColor = + MaterialTheme.colorScheme.primaryContainer), + modifier = + Modifier.testTag(setting.name).clickable { + navActions.navigateTo(setting.getDestination()) + }) + } + } - // The below part is association dependent, only available if you're an admin ! - if (state.isAdmin) { - Text( - text = "Manage ${state.selectedAssociation.name}", - style = MaterialTheme.typography.titleMedium) + // The below part is association dependent, only available if you're an admin ! + if (state.isAdmin) { + Text( + text = "Manage ${state.selectedAssociation.name}", + style = MaterialTheme.typography.titleMedium + ) - Column( - modifier = - Modifier.fillMaxWidth() - .testTag("manageAssociationList") - .clip(RoundedCornerShape(12.dp))) { - AssociationSettings.entries.forEach { s -> - ListItem( - leadingContent = { - Icon(imageVector = s.getIcon(), contentDescription = "${s.name} icon") - }, - headlineContent = { Text(text = s.toString()) }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowRight, - contentDescription = "Go to ${s.name} settings") - }, - colors = - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.primaryContainer), + Column( modifier = - Modifier.testTag(s.name).clickable { - navActions.navigateTo(s.getDestination()) - }) - } + Modifier.fillMaxWidth() + .testTag("manageAssociationList") + .clip(RoundedCornerShape(12.dp)) + ) { + AssociationSettings.entries.forEach { s -> + ListItem( + leadingContent = { + Icon( + imageVector = s.getIcon(), + contentDescription = "${s.name} icon" + ) + }, + headlineContent = { Text(text = s.toString()) }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowRight, + contentDescription = "Go to ${s.name} settings" + ) + }, + colors = + ListItemDefaults.colors( + containerColor = + MaterialTheme.colorScheme.primaryContainer + ), + modifier = + Modifier.testTag(s.name).clickable { + navActions.navigateTo(s.getDestination()) + }) + } + } } - } - // log out button (for everyone) - LogoutButton(viewmodel = viewmodel) + // log out button (for everyone) + LogoutButton(viewmodel = viewmodel) + } } // open dialog to edit member diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileViewModel.kt index 87d061f7b..5ad3ccc58 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileViewModel.kt @@ -22,6 +22,7 @@ import com.github.se.assocify.navigation.Destination import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.composables.DropdownOption import com.github.se.assocify.ui.util.SnackbarSystem +import com.github.se.assocify.ui.util.SyncSystem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -47,57 +48,42 @@ class ProfileViewModel( private val snackbarSystem = SnackbarSystem(_uiState.value.snackbarHostState) - private var loadCounter = 0 // number of things loading + private val loadSystem = + SyncSystem( + { _uiState.value = _uiState.value.copy(loading = false, refresh = false, error = null) }, + { error -> + _uiState.value = _uiState.value.copy(loading = false, refresh = false, error = error) + }) + + private val refreshSystem = + SyncSystem( + { loadProfile() }, + { error -> + _uiState.value = _uiState.value.copy(refresh = false) + snackbarSystem.showSnackbar(error) + }) init { loadProfile() } - /** This function is used to start loading. It increments the load counter. */ - private fun startLoading() { - _uiState.value = _uiState.value.copy(loading = true, error = null) - loadCounter += 4 - } - - /** - * This function is used to end loading. It decrements the load counter. - * - * @param error the error message, if any - */ - private fun endLoading(error: String? = null) { - if (error != null) { - if (_uiState.value.error == null) { - _uiState.value = _uiState.value.copy(loading = false, error = error) - } - loadCounter = 0 - } else if (--loadCounter == 0) { - _uiState.value = _uiState.value.copy(loading = false, error = null) - loadCounter = 0 - } - } - /** * This function is used to load the profile of the user. It gets the user's name, associations * and the current association. It also gets the user's role in the association. */ fun loadProfile() { - startLoading() + + if (!loadSystem.start(4)) return + + _uiState.value = _uiState.value.copy(loading = true, error = null) + userAPI.getUser( CurrentUser.userUid!!, { user -> _uiState.value = _uiState.value.copy(myName = user.name, modifyingName = user.name) - endLoading() + loadSystem.end() }, - { endLoading("Error loading profile") }) - userAPI.getProfilePicture( - "${CurrentUser.userUid!!}.jpg", - { uri -> _uiState.value = _uiState.value.copy(profileImageURI = uri) }, - { - CoroutineScope(Dispatchers.Main).launch { - _uiState.value.snackbarHostState.showSnackbar( - message = "Error loading profile picture", duration = SnackbarDuration.Short) - } - }) + { loadSystem.end("Error loading profile") }) userAPI.getCurrentUserAssociations( { associations -> _uiState.value = @@ -112,11 +98,11 @@ class ProfileViewModel( contentDescription = "Association Logo") } } + _uiState.value.defaultJoinAsso) - endLoading() + loadSystem.end() }, { _uiState.value = _uiState.value.copy(myAssociations = emptyList()) - endLoading("Error loading your associations") + loadSystem.end("Error loading your associations") }) assoAPI.getAssociation( CurrentUser.associationUid!!, @@ -131,22 +117,37 @@ class ProfileViewModel( imageVector = Icons.Default.People, contentDescription = "Association Logo") }) - endLoading() + loadSystem.end() }, { if (_uiState.value.myAssociations.isNotEmpty()) { _uiState.value = _uiState.value.copy(selectedAssociation = _uiState.value.myAssociations[0]) } - endLoading("Error loading current association") + loadSystem.end("Error loading current association") }) userAPI.getCurrentUserRole( { role -> _uiState.value = _uiState.value.copy(currentRole = role) setRoleCapacities() - endLoading() + loadSystem.end() }, - { endLoading("Error loading role") }) + { loadSystem.end("Error loading role") }) + + // This one is separate from the main loading system because it's not critical, + // therefore it doesn't need to block the screen in loading state + userAPI.getProfilePicture( + "${CurrentUser.userUid!!}.jpg", + { uri -> _uiState.value = _uiState.value.copy(profileImageURI = uri) }, + { snackbarSystem.showSnackbar("Error loading profile picture") }) + } + + fun refreshProfile() { + if (!refreshSystem.start(2)) return + _uiState.value = _uiState.value.copy(refresh = true) + + assoAPI.updateCache({ refreshSystem.end() }, { refreshSystem.end("Could not refresh") }) + userAPI.updateUserCache({ refreshSystem.end() }, { refreshSystem.end("Could not refresh") }) } /** @@ -194,7 +195,6 @@ class ProfileViewModel( _uiState.value = _uiState.value.copy(selectedAssociation = association) _uiState.value = _uiState.value.copy(currentRole = role) setRoleCapacities() - endLoading() }, { CurrentUser.associationUid = oldAssociationUid @@ -277,10 +277,12 @@ class ProfileViewModel( } data class ProfileUIState( - // wether the screen in loading + // whether the screen in loading val loading: Boolean = false, // the error message, if any val error: String? = null, + // whether the profile is being refreshed + val refresh: Boolean = false, // true if the user is an admin, false if not val isAdmin: Boolean = false, // the name of the user diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsGraph.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsGraph.kt index f6ecd917a..32465b528 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsGraph.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsGraph.kt @@ -9,7 +9,6 @@ import com.github.se.assocify.navigation.NavigationActions fun NavGraphBuilder.profileEventsGraph(navigationActions: NavigationActions, eventAPI: EventAPI) { composable(route = Destination.ProfileEvents.route) { ProfileEventsScreen( - navigationActions, - profileEventsViewModel = ProfileEventsViewModel(eventAPI, navigationActions)) + navigationActions, profileEventsViewModel = ProfileEventsViewModel(eventAPI)) } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsScreen.kt index b13e5e88f..22db7aa33 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsScreen.kt @@ -1,32 +1,46 @@ package com.github.se.assocify.ui.screens.profile.events +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets 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.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.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.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import com.github.se.assocify.navigation.NavigationActions @OptIn(ExperimentalMaterial3Api::class) @@ -35,6 +49,8 @@ fun ProfileEventsScreen( navActions: NavigationActions, profileEventsViewModel: ProfileEventsViewModel ) { + val state by profileEventsViewModel.uiState.collectAsState() + Scaffold( modifier = Modifier.testTag("ProfileEvents Screen"), topBar = { @@ -50,13 +66,13 @@ fun ProfileEventsScreen( }) }, floatingActionButton = { - FloatingActionButton(onClick = { /*TODO*/}, modifier = Modifier.testTag("addEventButton")) { - Icon(imageVector = Icons.Default.Add, contentDescription = "Add") - } + FloatingActionButton( + onClick = { profileEventsViewModel.openAddEvent() }, + modifier = Modifier.testTag("addEventButton")) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Add") + } }, contentWindowInsets = WindowInsets(20.dp, 0.dp, 20.dp, 0.dp)) { - val state by profileEventsViewModel.uiState.collectAsState() - LazyColumn( modifier = Modifier.fillMaxSize().padding(it), ) { @@ -64,16 +80,23 @@ fun ProfileEventsScreen( item { ListItem( headlineContent = { Text(text = event.name) }, - supportingContent = { Text(text = event.description.ifBlank { "-" }) }, + supportingContent = { + Text( + text = event.description.ifBlank { "-" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + }, trailingContent = { Row { IconButton( - onClick = { /*TODO*/}, modifier = Modifier.testTag("editEventButton")) { + onClick = { profileEventsViewModel.modifyEvent(event) }, + modifier = Modifier.testTag("editEventButton")) { Icon(Icons.Default.Edit, contentDescription = "Edit") } IconButton( - onClick = { /*TODO*/}, modifier = Modifier.testTag("deleteEventButton")) { + onClick = { profileEventsViewModel.openDeleteDialog(event) }, + modifier = Modifier.testTag("deleteEventButton-$index")) { Icon(Icons.Default.Delete, contentDescription = "Delete") } } @@ -82,6 +105,84 @@ fun ProfileEventsScreen( } } item { Spacer(modifier = Modifier.height(80.dp)) } + + // open dialog to edit/add event + if (state.openDialog) { + item { + Dialog(onDismissRequest = { profileEventsViewModel.clearModifyingEvent() }) { + ElevatedCard { + Column( + modifier = + Modifier.padding(16.dp).fillMaxWidth().testTag("updateEventDialog"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + // name of the event + OutlinedTextField( + value = state.modifyingEvent?.name ?: state.newName, + singleLine = true, + onValueChange = { profileEventsViewModel.updateNewName(it) }, + label = { Text("Edit name") }, + modifier = Modifier.fillMaxWidth().testTag("editName")) + + // description of the event + OutlinedTextField( + value = state.modifyingEvent?.description ?: state.newDescription, + singleLine = true, + onValueChange = { profileEventsViewModel.updateNewDescription(it) }, + label = { Text("Edit description") }, + modifier = Modifier.fillMaxWidth().testTag("editDescription")) + + // confirm button + OutlinedButton( + onClick = { + if (state.modifyingEvent != null) + profileEventsViewModel.updateCurrentEvent() + else profileEventsViewModel.confirmAddEvent() + }, + modifier = Modifier.wrapContentSize().testTag("confirmButton")) { + Text(text = "Confirm", textAlign = TextAlign.Center) + } + } + } + } + } + } + + // open dialog to confirm delete + if (state.deleteDialog) { + item { + Dialog(onDismissRequest = { profileEventsViewModel.clearDeleteDialog() }) { + ElevatedCard { + Column( + modifier = Modifier.padding(16.dp).testTag("deleteEventDialog"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + "Are you sure you want to delete the event ${state.deletingEvent?.name}?") + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedButton( + onClick = { profileEventsViewModel.clearDeleteDialog() }, + modifier = Modifier.wrapContentSize().testTag("cancelButton"), + ) { + Text(text = "Cancel", textAlign = TextAlign.Center) + } + OutlinedButton( + onClick = { + profileEventsViewModel.deleteEvent(state.deletingEvent!!) + }, + modifier = Modifier.wrapContentSize().testTag("confirmButton"), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error), + border = BorderStroke(1.0.dp, MaterialTheme.colorScheme.error)) { + Text(text = "Confirm", textAlign = TextAlign.Center) + } + } + } + } + } + } + } } } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsViewModel.kt index 001b2342a..8bec0f9c3 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/events/profileEventsViewModel.kt @@ -4,19 +4,158 @@ import android.util.Log import androidx.lifecycle.ViewModel import com.github.se.assocify.model.database.EventAPI import com.github.se.assocify.model.entities.Event -import com.github.se.assocify.navigation.NavigationActions import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class ProfileEventsViewModel(eventAPI: EventAPI, navActions: NavigationActions) : ViewModel() { +/** + * ViewModel for the events management screen + * + * @param eventAPI the API for events + */ +class ProfileEventsViewModel(private val eventAPI: EventAPI) : ViewModel() { private val _uiState = MutableStateFlow(ProfileEventsUIState()) val uiState: StateFlow = _uiState + private val loadEventError: String = "Error loading events" + init { eventAPI.getEvents( { eventList -> _uiState.value = _uiState.value.copy(events = eventList) }, - { Log.e("events", "Error loading events") }) + { Log.e("events", loadEventError) }) + } + + /** + * Opens the dialog for modifying an event and sets the event to modify + * + * @param event the event to modify + */ + fun modifyEvent(event: Event) { + _uiState.value = _uiState.value.copy(modifyingEvent = event, openDialog = true) + } + + /** Closes the dialog for modifying/adding an event without saving the changes */ + fun clearModifyingEvent() { + _uiState.value = _uiState.value.copy(modifyingEvent = null, openDialog = false) + } + + /** Opens the dialog for adding an event */ + fun openAddEvent() { + _uiState.value = _uiState.value.copy(openDialog = true) + } + + /** + * Opens the dialog for deleting an event and sets the event to delete + * + * @param event the event to delete + */ + fun openDeleteDialog(event: Event) { + _uiState.value = _uiState.value.copy(deletingEvent = event, deleteDialog = true) + } + + /** Closes the dialog for deleting an event without deleting the event, cancel */ + fun clearDeleteDialog() { + _uiState.value = _uiState.value.copy(deleteDialog = false, deletingEvent = null) + } + + /** Updates the name of the event currently being modified */ + fun updateNewName(name: String) { + _uiState.value = + _uiState.value.copy( + newName = name, + modifyingEvent = + _uiState.value.modifyingEvent?.copy(name = name) ?: _uiState.value.modifyingEvent) + } + + /** Updates the description of the event currently being modified */ + fun updateNewDescription(description: String) { + _uiState.value = + _uiState.value.copy( + newDescription = description, + modifyingEvent = + _uiState.value.modifyingEvent?.copy(description = description) + ?: _uiState.value.modifyingEvent) + } + + /** Confirms the deletion of the event */ + fun deleteEvent(event: Event) { + eventAPI.deleteEvent( + event.uid, + { + _uiState.value = _uiState.value.copy(deleteDialog = false, deletingEvent = null) + eventAPI.getEvents( + { eventList -> _uiState.value = _uiState.value.copy(events = eventList) }, + { Log.e("events", loadEventError) }) + }, + { Log.e("events", "Error deleting event") }) + } + + /** Updates the event that is currently being modified */ + fun updateCurrentEvent() { + eventAPI.updateEvent( + _uiState.value.modifyingEvent!!, + { + eventAPI.getEvents( + { eventList -> + _uiState.value = + _uiState.value.copy( + events = eventList, + openDialog = false, + modifyingEvent = null, + newName = "", + newDescription = "", + ) + }, + { Log.e("events", loadEventError) }) + }, + { Log.e("events", "Error updating event") }) + } + + /** Adds the event that is being created */ + fun confirmAddEvent() { + val event = Event(name = _uiState.value.newName, description = _uiState.value.newDescription) + eventAPI.addEvent( + event, + { + eventAPI.getEvents( + { eventList -> + _uiState.value = + _uiState.value.copy( + events = eventList, + openDialog = false, + newName = "", + newDescription = "", + ) + }, + { Log.e("events", loadEventError) }) + }, + { Log.e("events", "Error adding event") }) } } -data class ProfileEventsUIState(val events: List = emptyList()) +/** + * The UI state for the events management screen + * + * @param events the list of current events + * @param modifyingEvent the event that is currently being modified + * @param deletingEvent the event that is currently being deleted + * @param openDialog whether the dialog for adding or modifying an event is open + * @param deleteDialog whether the dialog for confirming the deletion of an event is open + * @param newName the name for the new event + * @param newDescription the description for the new event + */ +data class ProfileEventsUIState( + // The list of current events + val events: List = emptyList(), + // The event that is currently being modified + val modifyingEvent: Event? = null, + // The event that is currently being deleted + val deletingEvent: Event? = null, + // Whether the dialog for adding or modifying an event is open + val openDialog: Boolean = false, + // Whether the dialog for confirming the deletion of an event is open + val deleteDialog: Boolean = false, + // The name for the new event + val newName: String = "", + // The description for the new event + val newDescription: String = "", +) diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsGraph.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsGraph.kt similarity index 71% rename from app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsGraph.kt rename to app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsGraph.kt index 74002a31c..b7d31b0aa 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsGraph.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsGraph.kt @@ -1,5 +1,6 @@ package com.github.se.assocify.ui.screens.profile.treasuryTags +import androidx.compose.runtime.remember import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.github.se.assocify.model.database.AccountingCategoryAPI @@ -11,7 +12,9 @@ fun NavGraphBuilder.profileTreasuryTagsGraph( accountingCategoryAPI: AccountingCategoryAPI ) { composable(route = Destination.ProfileTreasuryTags.route) { - ProfileTreasuryTagsScreen( - navigationActions, ProfileTreasuryTagsViewModel(accountingCategoryAPI, navigationActions)) + val viewModel = remember { + ProfileTreasuryTagsViewModel(accountingCategoryAPI, navigationActions) + } + ProfileTreasuryTagsScreen(navigationActions, viewModel) } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsScreen.kt new file mode 100644 index 000000000..5571eb6e2 --- /dev/null +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsScreen.kt @@ -0,0 +1,191 @@ +package com.github.se.assocify.ui.screens.profile.treasuryTags + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Card +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.github.se.assocify.model.entities.AccountingCategory +import com.github.se.assocify.navigation.NavigationActions +import com.github.se.assocify.ui.composables.BackButton +import com.github.se.assocify.ui.composables.CenteredCircularIndicator +import com.github.se.assocify.ui.composables.ErrorMessagePage +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileTreasuryTagsScreen( + navActions: NavigationActions, + treasuryTagsViewModel: ProfileTreasuryTagsViewModel +) { + val state by treasuryTagsViewModel.uiState.collectAsState() + + if (state.loading) { + CenteredCircularIndicator() + return + } + + if (state.error != null) { + ErrorMessagePage(errorMessage = state.error, onBack = { navActions.back() }) { + treasuryTagsViewModel.fetchCategories() + } + return + } + + if (state.creating || state.modify) { + NamePopUp(treasuryTagsViewModel = treasuryTagsViewModel) + } + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = state.snackBarHostState, + snackbar = { snackbarData -> + Snackbar(snackbarData = snackbarData, modifier = Modifier.testTag("snackbar")) + }) + }, + modifier = Modifier.testTag("TreasuryTags Screen"), + topBar = { + CenterAlignedTopAppBar( + title = { Text("Treasury Tags Management") }, + navigationIcon = { + BackButton( + contentDescription = "Arrow Back", + onClick = { navActions.back() }, + modifier = Modifier.testTag("backButton")) + }) + }, + contentWindowInsets = WindowInsets(20.dp, 0.dp, 20.dp, 0.dp), + floatingActionButton = { + FloatingActionButton( + onClick = { treasuryTagsViewModel.creating(true) }, + modifier = Modifier.testTag("addTagButton")) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Add") + } + }) { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(it), + ) { + state.treasuryTags.forEachIndexed { i, treasuryTag -> + item { + ListItem( + headlineContent = { Text(text = treasuryTag.name) }, + trailingContent = { + Row { + IconButton( + onClick = { treasuryTagsViewModel.modifying(true, treasuryTag) }, + modifier = Modifier.testTag(treasuryTag.uid.toString())) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + + IconButton( + onClick = { treasuryTagsViewModel.deleteTag(treasuryTag) }, + modifier = Modifier.testTag("deleteTagButton")) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + } + }) + if (i < state.treasuryTags.size - 1) HorizontalDivider() + } + } + item { Spacer(modifier = Modifier.height(80.dp)) } + } + } +} + +@Composable +fun NamePopUp(treasuryTagsViewModel: ProfileTreasuryTagsViewModel) { + val state by treasuryTagsViewModel.uiState.collectAsState() + val tag = state.editedTag ?: AccountingCategory(UUID.randomUUID().toString(), "") + var nameString by remember { mutableStateOf(tag.name) } + Dialog(onDismissRequest = { treasuryTagsViewModel.cancelPopUp() }) { + Card( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp), + shape = RoundedCornerShape(16.dp), + ) { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(8.dp)) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text(state.displayedName, style = MaterialTheme.typography.titleLarge) + Icon( + Icons.Default.Close, + contentDescription = "Close dialog", + modifier = Modifier.clickable { treasuryTagsViewModel.cancelPopUp() }) + } + } + item { + OutlinedTextField( + singleLine = true, + isError = state.nameError, + modifier = Modifier.padding(8.dp).testTag("nameField"), + value = nameString, + onValueChange = { + nameString = it + treasuryTagsViewModel.checkNameError(it) + }, + label = { Text("Name") }, + supportingText = { + Text(if (state.nameError) "The string is not correct!" else "") + }) + } + item { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + content = { Text("Save") }, + onClick = { + treasuryTagsViewModel.checkNameError(nameString) + val newTag = AccountingCategory(tag.uid, nameString) + if (state.modify) treasuryTagsViewModel.modifyTag(newTag) + else treasuryTagsViewModel.addTag(newTag) + }, + modifier = Modifier.testTag("saveButton"), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsViewModel.kt new file mode 100644 index 000000000..72f7e14a0 --- /dev/null +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/ProfileTreasuryTagsViewModel.kt @@ -0,0 +1,122 @@ +package com.github.se.assocify.ui.screens.profile.treasuryTags + +import android.util.Log +import androidx.compose.material3.SnackbarHostState +import androidx.lifecycle.ViewModel +import com.github.se.assocify.model.CurrentUser +import com.github.se.assocify.model.database.AccountingCategoryAPI +import com.github.se.assocify.model.entities.AccountingCategory +import com.github.se.assocify.navigation.NavigationActions +import com.github.se.assocify.ui.util.SnackbarSystem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class ProfileTreasuryTagsViewModel( + private val accountingCategoryAPI: AccountingCategoryAPI, + navActions: NavigationActions +) : ViewModel() { + private val _uiState = MutableStateFlow(ProfileTreasuryTagsUIState()) + val uiState: StateFlow = _uiState + + private val snackBarSystem = SnackbarSystem(_uiState.value.snackBarHostState) + + init { + fetchCategories() + } + + fun fetchCategories() { + _uiState.value = _uiState.value.copy(loading = true) + accountingCategoryAPI.getCategories( + CurrentUser.associationUid!!, + { categoryList -> + _uiState.value = _uiState.value.copy(treasuryTags = categoryList) + _uiState.value = _uiState.value.copy(loading = false, error = null) + }, + { _uiState.value = _uiState.value.copy(loading = false, error = "Error loading tags") }) + } + + fun modifying(modifying: Boolean, editedTag: AccountingCategory) { + _uiState.value = + _uiState.value.copy( + modify = modifying, displayedName = "Editing tag", editedTag = editedTag) + } + + fun creating(modifying: Boolean) { + _uiState.value = _uiState.value.copy(creating = modifying, displayedName = "Creating tag") + } + + fun deleteTag(tag: AccountingCategory) { + if (_uiState.value.nameError) return + accountingCategoryAPI.deleteCategory( + tag, + { + _uiState.value = + _uiState.value.copy(treasuryTags = _uiState.value.treasuryTags.filter { tag != it }) + }, + { snackBarSystem.showSnackbar("Could not delete the tag") }) + } + + fun addTag(newTag: AccountingCategory) { + if (_uiState.value.nameError) return + accountingCategoryAPI.addCategory( + associationUID = _uiState.value.assocId, + newTag, + { + _uiState.value = _uiState.value.copy(treasuryTags = _uiState.value.treasuryTags + newTag) + cancelPopUp() + }, + { + Log.e("TrasurySCreen", "does not work!!") + cancelPopUp() + snackBarSystem.showSnackbar("Could not add the tag") + }) + } + + fun modifyTag(tag: AccountingCategory) { + if (_uiState.value.nameError) return + accountingCategoryAPI.updateCategory( + _uiState.value.assocId, + tag, + { + _uiState.value = + _uiState.value.copy( + treasuryTags = _uiState.value.treasuryTags.filter { it.uid != tag.uid } + tag) + cancelPopUp() + }, + { + Log.e("TrasurySCreen", "does not work!!") + cancelPopUp() + snackBarSystem.showSnackbar("Could not modify the tag") + }) + } + + fun cancelPopUp() { + _uiState.value = + _uiState.value.copy( + modify = false, + creating = false, + editedTag = null, + displayedName = "", + nameError = false) + } + + fun checkNameError(name: String) { + _uiState.value = + _uiState.value.copy( + nameError = name.isEmpty() || _uiState.value.treasuryTags.any { ac -> ac.name == name }) + } +} + +data class ProfileTreasuryTagsUIState( + val assocId: String = CurrentUser.associationUid!!, + val treasuryTags: List = emptyList(), + val modify: Boolean = false, + val creating: Boolean = false, + val editedTag: AccountingCategory? = null, + val displayedName: String = "", + val error: String? = null, + val loading: Boolean = false, + val snackBarError: String? = null, + val nameError: Boolean = false, + val snackBarHostState: SnackbarHostState = SnackbarHostState() +) diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsScreen.kt deleted file mode 100644 index 383a8dcb1..000000000 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsScreen.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.se.assocify.ui.screens.profile.treasuryTags - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp -import com.github.se.assocify.navigation.NavigationActions -import com.github.se.assocify.ui.composables.BackButton - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProfileTreasuryTagsScreen( - navActions: NavigationActions, - treasuryTagsViewModel: ProfileTreasuryTagsViewModel -) { - val state by treasuryTagsViewModel.uiState.collectAsState() - - Scaffold( - modifier = Modifier.testTag("TreasuryTags Screen"), - topBar = { - CenterAlignedTopAppBar( - title = { Text("Treasury Tags Management") }, - navigationIcon = { - BackButton( - contentDescription = "Arrow Back", - onClick = { navActions.back() }, - modifier = Modifier.testTag("backButton")) - }) - }, - contentWindowInsets = WindowInsets(20.dp, 0.dp, 20.dp, 0.dp), - floatingActionButton = { - FloatingActionButton(onClick = { /*TODO*/}, modifier = Modifier.testTag("addTagButton")) { - Icon(imageVector = Icons.Default.Add, contentDescription = "Add") - } - }) { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(it), - ) { - state.treasuryTags.forEachIndexed { i, treasuryTag -> - item { - ListItem( - headlineContent = { Text(text = treasuryTag.name) }, - trailingContent = { - Row { - IconButton( - onClick = { /*TODO*/}, modifier = Modifier.testTag("editTagButton")) { - Icon(Icons.Default.Edit, contentDescription = "Edit") - } - - IconButton( - onClick = { /*TODO*/}, modifier = Modifier.testTag("deleteTagButton")) { - Icon(Icons.Default.Delete, contentDescription = "Delete") - } - } - }) - if (i < state.treasuryTags.size - 1) HorizontalDivider() - } - } - item { Spacer(modifier = Modifier.height(80.dp)) } - } - } -} diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsViewModel.kt deleted file mode 100644 index b4818a41d..000000000 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/treasuryTags/profileTreasuryTagsViewModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.se.assocify.ui.screens.profile.treasuryTags - -import android.util.Log -import androidx.lifecycle.ViewModel -import com.github.se.assocify.model.CurrentUser -import com.github.se.assocify.model.database.AccountingCategoryAPI -import com.github.se.assocify.model.entities.AccountingCategory -import com.github.se.assocify.navigation.NavigationActions -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -class ProfileTreasuryTagsViewModel( - accountingCategoryAPI: AccountingCategoryAPI, - navActions: NavigationActions -) : ViewModel() { - private val _uiState = MutableStateFlow(ProfileTreasuryTagsUIState()) - val uiState: StateFlow = _uiState - - init { - accountingCategoryAPI.getCategories( - CurrentUser.associationUid!!, - { categoryList -> _uiState.value = _uiState.value.copy(treasuryTags = categoryList) }, - { Log.e("treasurytags", "Error loading tags") }) - } -} - -data class ProfileTreasuryTagsUIState(val treasuryTags: List = emptyList()) diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryGraph.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryGraph.kt index 51eb25b94..ebcf5098b 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryGraph.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryGraph.kt @@ -8,12 +8,11 @@ import com.github.se.assocify.model.database.AccountingSubCategoryAPI import com.github.se.assocify.model.database.BalanceAPI import com.github.se.assocify.model.database.BudgetAPI import com.github.se.assocify.model.database.ReceiptAPI +import com.github.se.assocify.model.database.UserAPI import com.github.se.assocify.navigation.Destination import com.github.se.assocify.navigation.NavigationActions -import com.github.se.assocify.ui.screens.treasury.accounting.AccountingViewModel import com.github.se.assocify.ui.screens.treasury.accounting.balance.balanceDetailedGraph import com.github.se.assocify.ui.screens.treasury.accounting.budget.budgetDetailedGraph -import com.github.se.assocify.ui.screens.treasury.receiptstab.ReceiptListViewModel import com.github.se.assocify.ui.screens.treasury.receiptstab.receipt.receiptGraph fun NavGraphBuilder.treasuryGraph( @@ -22,20 +21,24 @@ fun NavGraphBuilder.treasuryGraph( balanceAPI: BalanceAPI, receiptsAPI: ReceiptAPI, accountingCategoryAPI: AccountingCategoryAPI, - accountingSubCategoryAPI: AccountingSubCategoryAPI + accountingSubCategoryAPI: AccountingSubCategoryAPI, + userAPI: UserAPI ) { composable( route = Destination.Treasury.route, ) { - val receiptListViewModel = remember { ReceiptListViewModel(navigationActions, receiptsAPI) } - val accountingViewModel = remember { - AccountingViewModel(accountingCategoryAPI, accountingSubCategoryAPI, balanceAPI, budgetAPI) - } val treasuryViewModel = remember { - TreasuryViewModel(navigationActions, receiptListViewModel, accountingViewModel) + TreasuryViewModel( + navigationActions, + receiptsAPI, + accountingCategoryAPI, + accountingSubCategoryAPI, + balanceAPI, + budgetAPI, + userAPI) } - TreasuryScreen(navigationActions, accountingViewModel, receiptListViewModel, treasuryViewModel) + TreasuryScreen(navigationActions, treasuryViewModel) } receiptGraph(navigationActions, receiptsAPI) budgetDetailedGraph( diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryScreen.kt index 2e1c7c31b..fafa87423 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryScreen.kt @@ -13,12 +13,15 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import com.github.se.assocify.model.entities.RoleType import com.github.se.assocify.navigation.Destination import com.github.se.assocify.navigation.MAIN_TABS_LIST import com.github.se.assocify.navigation.NavigationActions @@ -42,15 +45,13 @@ import com.github.se.assocify.ui.screens.treasury.receiptstab.ReceiptListViewMod */ @OptIn(ExperimentalFoundationApi::class) @Composable -fun TreasuryScreen( - navActions: NavigationActions, - accountingViewModel: AccountingViewModel, - receiptListViewModel: ReceiptListViewModel, - treasuryViewModel: TreasuryViewModel -) { +fun TreasuryScreen(navActions: NavigationActions, treasuryViewModel: TreasuryViewModel) { + + val accountingViewModel: AccountingViewModel = treasuryViewModel.accountingViewModel + val receiptListViewModel: ReceiptListViewModel = treasuryViewModel.receiptListViewModel val treasuryState by treasuryViewModel.uiState.collectAsState() - val accountingState by accountingViewModel.uiState.collectAsState() + val receiptState by receiptListViewModel.uiState.collectAsState() val pagerState = rememberPagerState(pageCount = { TreasuryPageIndex.entries.size }) Scaffold( @@ -92,22 +93,32 @@ fun TreasuryScreen( Icon(Icons.Outlined.Add, "Create") } }, + snackbarHost = { + SnackbarHost( + hostState = treasuryState.snackbarHostState, + snackbar = { snackbarData -> Snackbar(snackbarData = snackbarData) }) + }, contentWindowInsets = WindowInsets(20.dp, 0.dp, 20.dp, 0.dp)) { innerPadding -> Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { - InnerTabRow( - tabList = TreasuryPageIndex.entries, - pagerState = pagerState, - switchTab = { tab -> treasuryViewModel.switchTab(tab) }) + if (receiptState.userCurrentRole.type != RoleType.TREASURY && + receiptState.userCurrentRole.type != RoleType.PRESIDENCY) { + ReceiptListScreen(viewModel = receiptListViewModel) + } else { + InnerTabRow( + tabList = TreasuryPageIndex.entries, + pagerState = pagerState, + switchTab = { tab -> treasuryViewModel.switchTab(tab) }) - when (pagerState.currentPage) { - TreasuryPageIndex.Receipts.ordinal -> {} - TreasuryPageIndex.Budget.ordinal -> { - AccountingFilterBar(accountingViewModel) - AddSubcategoryDialog(accountingViewModel) - } - TreasuryPageIndex.Balance.ordinal -> { - AccountingFilterBar(accountingViewModel) - AddSubcategoryDialog(accountingViewModel) + when (pagerState.currentPage) { + TreasuryPageIndex.Receipts.ordinal -> {} + TreasuryPageIndex.Budget.ordinal -> { + AccountingFilterBar(accountingViewModel) + AddSubcategoryDialog(accountingViewModel) + } + TreasuryPageIndex.Balance.ordinal -> { + AccountingFilterBar(accountingViewModel) + AddSubcategoryDialog(accountingViewModel) + } } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryViewModel.kt index 0345ec21c..007271c16 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/TreasuryViewModel.kt @@ -1,24 +1,40 @@ package com.github.se.assocify.ui.screens.treasury +import androidx.compose.material3.SnackbarHostState +import com.github.se.assocify.model.database.AccountingCategoryAPI +import com.github.se.assocify.model.database.AccountingSubCategoryAPI +import com.github.se.assocify.model.database.BalanceAPI +import com.github.se.assocify.model.database.BudgetAPI +import com.github.se.assocify.model.database.ReceiptAPI +import com.github.se.assocify.model.database.UserAPI import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.screens.treasury.accounting.AccountingViewModel import com.github.se.assocify.ui.screens.treasury.receiptstab.ReceiptListViewModel +import com.github.se.assocify.ui.util.SnackbarSystem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class TreasuryViewModel( - private val navActions: NavigationActions, - private val receiptListViewModel: ReceiptListViewModel, - private val accountingViewModel: AccountingViewModel + navActions: NavigationActions, + receiptsAPI: ReceiptAPI, + accountingCategoryAPI: AccountingCategoryAPI, + accountingSubCategoryAPI: AccountingSubCategoryAPI, + balanceAPI: BalanceAPI, + budgetAPI: BudgetAPI, + userAPI: UserAPI ) { // ViewModel states private val _uiState: MutableStateFlow = MutableStateFlow(TreasuryUIState()) - val uiState: StateFlow + val uiState: StateFlow = _uiState - init { - _uiState.value = TreasuryUIState() - uiState = _uiState - } + private val snackbarSystem = SnackbarSystem(_uiState.value.snackbarHostState) + + val receiptListViewModel: ReceiptListViewModel = + ReceiptListViewModel(navActions, receiptsAPI, snackbarSystem, userAPI) + + val accountingViewModel: AccountingViewModel = + AccountingViewModel( + accountingCategoryAPI, accountingSubCategoryAPI, balanceAPI, budgetAPI, snackbarSystem) fun setSearchQuery(query: String) { _uiState.value = _uiState.value.copy(searchQuery = query) @@ -54,6 +70,7 @@ class TreasuryViewModel( * @param searchQuery the current search query */ data class TreasuryUIState( + val snackbarHostState: SnackbarHostState = SnackbarHostState(), val searchQuery: String = "", val currentTab: TreasuryPageIndex = TreasuryPageIndex.Receipts ) diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/AccountingScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/AccountingScreen.kt index 039a57b65..4f12af8b4 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/AccountingScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/AccountingScreen.kt @@ -29,6 +29,7 @@ import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.composables.CenteredCircularIndicator import com.github.se.assocify.ui.composables.DropdownFilterChip import com.github.se.assocify.ui.composables.ErrorMessage +import com.github.se.assocify.ui.composables.PullDownRefreshBox import com.github.se.assocify.ui.util.DateUtil import com.github.se.assocify.ui.util.PriceUtil @@ -64,57 +65,62 @@ fun AccountingScreen( return } - LazyColumn( - modifier = Modifier.fillMaxWidth().testTag("AccountingScreen"), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + PullDownRefreshBox( + refreshing = accountingState.refresh, + onRefresh = { accountingViewModel.refreshAccounting() }, ) { - // display the subcategory if list is not empty - if (subCategoryList.isNotEmpty()) { - items(subCategoryList) { - DisplayLine(it, "displayLine${it.name}", page, navigationActions, accountingState) - HorizontalDivider(Modifier.fillMaxWidth()) - } - item { - val totalAmount = - when (page) { - AccountingPage.BUDGET -> { - if (accountingState.tvaFilterActive) - accountingState.amountBudgetTTC - .filter { it.key in subCategoryList.map { it.uid } } - .values - .sum() - else - accountingState.amountBudgetHT - .filter { it.key in subCategoryList.map { it.uid } } - .values - .sum() - } - AccountingPage.BALANCE -> { - if (accountingState.tvaFilterActive) - accountingState.amountBalanceTTC - .filter { it.key in subCategoryList.map { it.uid } } - .values - .sum() - else - accountingState.amountBalanceHT - .filter { it.key in subCategoryList.map { it.uid } } - .values - .sum() + LazyColumn( + modifier = Modifier.fillMaxWidth().testTag("AccountingScreen"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // display the subcategory if list is not empty + if (subCategoryList.isNotEmpty()) { + items(subCategoryList) { + DisplayLine(it, "displayLine${it.name}", page, navigationActions, accountingState) + HorizontalDivider(Modifier.fillMaxWidth()) + } + item { + val totalAmount = + when (page) { + AccountingPage.BUDGET -> { + if (accountingState.tvaFilterActive) + accountingState.amountBudgetTTC + .filter { it.key in subCategoryList.map { it.uid } } + .values + .sum() + else + accountingState.amountBudgetHT + .filter { it.key in subCategoryList.map { it.uid } } + .values + .sum() + } + AccountingPage.BALANCE -> { + if (accountingState.tvaFilterActive) + accountingState.amountBalanceTTC + .filter { it.key in subCategoryList.map { it.uid } } + .values + .sum() + else + accountingState.amountBalanceHT + .filter { it.key in subCategoryList.map { it.uid } } + .values + .sum() + } } - } - TotalLine(totalAmount = totalAmount) + TotalLine(totalAmount = totalAmount) + } + } else { + item { + Text( + text = "No data available with these tags", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + ) + } } - } else { - item { - Text( - text = "No data available with these tags", - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), - ) - } - } - item { Spacer(modifier = Modifier.height(80.dp)) } + item { Spacer(modifier = Modifier.height(80.dp)) } + } } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/AccountingViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/AccountingViewModel.kt index 2a01e1ec4..edd185634 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/AccountingViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/AccountingViewModel.kt @@ -11,6 +11,8 @@ import com.github.se.assocify.model.entities.AccountingCategory import com.github.se.assocify.model.entities.AccountingSubCategory import com.github.se.assocify.model.entities.BalanceItem import com.github.se.assocify.model.entities.BudgetItem +import com.github.se.assocify.ui.util.SnackbarSystem +import com.github.se.assocify.ui.util.SyncSystem import java.time.Year import java.util.UUID import kotlinx.coroutines.flow.MutableStateFlow @@ -28,44 +30,66 @@ class AccountingViewModel( private var accountingCategoryAPI: AccountingCategoryAPI, private var accountingSubCategoryAPI: AccountingSubCategoryAPI, private var balanceAPI: BalanceAPI, - private var budgetAPI: BudgetAPI + private var budgetAPI: BudgetAPI, + private val snackbarSystem: SnackbarSystem ) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(AccountingState()) val uiState: StateFlow + private val loadSystem = + SyncSystem( + { + filterSubCategories() + setSubcategoriesAmount() + _uiState.value = _uiState.value.copy(refresh = false, loading = false, error = null) + }, + { _uiState.value = _uiState.value.copy(refresh = false, loading = false, error = it) }) + + private val refreshSystem = + SyncSystem( + { loadAccounting() }, + { + _uiState.value = _uiState.value.copy(refresh = false) + snackbarSystem.showSnackbar(it) + }) + /** Initialize the view model */ init { - loadAccounting() uiState = _uiState - } - - private var loadCounter = 0 - - private fun startLoad(count: Int) { - loadCounter = count - _uiState.value = _uiState.value.copy(loading = true, error = null) - } - - private fun endLoad(error: String? = null) { - loadCounter-- - if (error != null) { - _uiState.value = _uiState.value.copy(loading = false, error = error) - } else if (loadCounter == 0) { - filterSubCategories() - setSubcategoriesAmount() - _uiState.value = _uiState.value.copy(loading = false, error = null) - } + loadAccounting() } /** Function to load categories and subcategories */ fun loadAccounting() { - startLoad(4) + if (!loadSystem.start(4)) return + _uiState.value = _uiState.value.copy(loading = true, error = null) getCategories() getSubCategories() getAccountingList() } + fun refreshAccounting() { + if (!refreshSystem.start(4)) return + _uiState.value = _uiState.value.copy(refresh = true) + accountingSubCategoryAPI.updateSubCategoryCache( + CurrentUser.associationUid!!, + { refreshSystem.end() }, + { refreshSystem.end("Error refreshing accounting") }) + accountingCategoryAPI.updateCategoryCache( + CurrentUser.associationUid!!, + { refreshSystem.end() }, + { refreshSystem.end("Error refreshing tags") }) + budgetAPI.updateBudgetCache( + CurrentUser.associationUid!!, + { refreshSystem.end() }, + { refreshSystem.end("Error refreshing budget") }) + balanceAPI.updateBalanceCache( + CurrentUser.associationUid!!, + { refreshSystem.end() }, + { refreshSystem.end("Error refreshing balance") }) + } + /** Function to get the categories from the database */ private fun getCategories() { // Sets the category list in the state from the database @@ -73,9 +97,9 @@ class AccountingViewModel( CurrentUser.associationUid!!, { categoryList -> _uiState.value = _uiState.value.copy(categoryList = categoryList) - endLoad() + loadSystem.end() }, - { endLoad("Error loading tags") }) + { loadSystem.end("Error loading tags") }) } /** Function to get the subcategories from the database */ @@ -85,9 +109,9 @@ class AccountingViewModel( CurrentUser.associationUid!!, { subCategoryList -> _uiState.value = _uiState.value.copy(allSubCategoryList = subCategoryList) - endLoad() + loadSystem.end() }, - { endLoad("Error loading categories") }) + { loadSystem.end("Error loading categories") }) } /** Function to filter the subCategoryList */ @@ -121,26 +145,26 @@ class AccountingViewModel( CurrentUser.associationUid!!, { budgetList -> _uiState.value = _uiState.value.copy(budgetItemsList = budgetList) - endLoad() + loadSystem.end() }, - { endLoad("Error loading budget") }) + { loadSystem.end("Error loading budget") }) // get the balanceItem List balanceAPI.getBalance( CurrentUser.associationUid!!, { balanceList -> _uiState.value = _uiState.value.copy(balanceItemList = balanceList) - endLoad() + loadSystem.end() }, - { endLoad("Error loading balance") }) + { loadSystem.end("Error loading balance") }) } /** Set the amount of a subcategory */ private fun setSubcategoriesAmount() { - val updatedAmountBalanceHT = _uiState.value.amountBalanceHT.toMutableMap() - val updatedAmountBalanceTTC = _uiState.value.amountBalanceTTC.toMutableMap() - val updatedAmountBudgetHT = _uiState.value.amountBudgetHT.toMutableMap() - val updatedAmountBudgetTTC = _uiState.value.amountBudgetTTC.toMutableMap() + val updatedAmountBalanceHT: MutableMap = mutableMapOf() + val updatedAmountBalanceTTC: MutableMap = mutableMapOf() + val updatedAmountBudgetHT: MutableMap = mutableMapOf() + val updatedAmountBudgetTTC: MutableMap = mutableMapOf() val balanceItemsBySubCategory = _uiState.value.balanceItemList.groupBy { it.subcategoryUID } val budgetItemsBySubCategory = _uiState.value.budgetItemsList.groupBy { it.subcategoryUID } @@ -324,6 +348,7 @@ class AccountingViewModel( data class AccountingState( val loading: Boolean = false, val error: String? = null, + val refresh: Boolean = false, val categoryList: List = emptyList(), val selectedCategory: AccountingCategory? = null, val subCategoryList: List = emptyList(), diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/balance/BalanceDetailedViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/balance/BalanceDetailedViewModel.kt index 7ffa3fec3..cfc793e6b 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/balance/BalanceDetailedViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/balance/BalanceDetailedViewModel.kt @@ -1,5 +1,6 @@ package com.github.se.assocify.ui.screens.treasury.accounting.balance +import android.util.Log import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.ViewModel import com.github.se.assocify.model.CurrentUser @@ -84,17 +85,22 @@ class BalanceDetailedViewModel( /** Update the database values */ private fun updateDatabaseValuesInBalance() { - var innerLoadCounter = 2 + var innerLoadCounter = 4 receiptAPI.getAllReceipts( - { receiptList -> _uiState.value = _uiState.value.copy(receiptList = receiptList) }, {}) + { receiptList -> + _uiState.value = _uiState.value.copy(receiptList = receiptList) + if (--innerLoadCounter == 0) endLoad() + }, + { endLoad("Error loading receipts") }) subCategoryAPI.getSubCategories( CurrentUser.associationUid!!, { subCategoryList -> _uiState.value = _uiState.value.copy(subCategoryList = subCategoryList) + if (--innerLoadCounter == 0) endLoad() }, - {}) + { endLoad("Error loading balance category") }) balanceApi.getBalance( CurrentUser.associationUid!!, @@ -229,6 +235,7 @@ class BalanceDetailedViewModel( _uiState.value.errorAssignee != null || _uiState.value.errorDescription != null || _uiState.value.errorDate != null) { + Log.e("BalanceDetailedViewModel", "Error in editing") return } // update the status of the receipt @@ -339,14 +346,6 @@ class BalanceDetailedViewModel( } } - fun checkReceipt(receiptUid: String) { - if (receiptUid.isEmpty() && !_uiState.value.noReceiptSelected) { - _uiState.value = _uiState.value.copy(errorReceipt = "The receipt cannot be empty!") - } else { - _uiState.value = _uiState.value.copy(errorReceipt = null) - } - } - fun checkAmount(amount: String) { if (amount.isEmpty()) { _uiState.value = _uiState.value.copy(errorAmount = "You cannot have an empty amount!") @@ -386,14 +385,14 @@ class BalanceDetailedViewModel( fun checkAll( name: String, - receiptUid: String, + receiptUid: String?, amount: String, assignee: String, description: String, date: LocalDate ) { checkName(name) - checkReceipt(receiptUid) + _uiState.value = _uiState.value.copy(errorReceipt = null) checkAmount(amount) checkAssignee(assignee) checkDescription(description) @@ -479,7 +478,7 @@ data class BalanceItemState( val snackbarState: SnackbarHostState = SnackbarHostState(), val filterActive: Boolean = false, val errorName: String? = "", - val errorReceipt: String? = "", + val errorReceipt: String? = null, val errorAmount: String? = "", val errorAssignee: String? = "", val errorDescription: String? = "", diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/balance/BalancePopUpScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/balance/BalancePopUpScreen.kt index 031a3d949..45e842ca6 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/balance/BalancePopUpScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/balance/BalancePopUpScreen.kt @@ -55,18 +55,15 @@ fun BalancePopUpScreen(balanceDetailedViewModel: BalanceDetailedViewModel) { date = LocalDate.now(), nameItem = "", subcategoryUID = "", - receiptUID = "", + receiptUID = null, description = "", assignee = "", uid = UUID.randomUUID().toString()) var nameString by remember { mutableStateOf(balance.nameItem) } - var receiptUid by remember { mutableStateOf(balance.receiptUID) } + var receiptUid: String? by remember { mutableStateOf(balance.receiptUID) } var receiptName by remember { mutableStateOf( - balanceModel.receiptList - .filter { it.uid == balance.receiptUID } - .map { it.title } - .getOrElse(0) { "" }) + balanceModel.receiptList.find { it.uid == balance.receiptUID }?.title ?: "No receipt") } var amountString by remember { mutableStateOf(PriceUtil.fromCents(balance.amount)) } var tvaString by remember { mutableStateOf(balance.tva.rate.toString()) } @@ -126,7 +123,7 @@ fun BalancePopUpScreen(balanceDetailedViewModel: BalanceDetailedViewModel) { isError = balanceModel.errorReceipt != null, supportingText = { Text(balanceModel.errorReceipt ?: "") }, value = receiptName, - onValueChange = { balanceDetailedViewModel.checkReceipt(receiptName) }, + onValueChange = {}, label = { Text("Receipt") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = receiptExpanded) @@ -145,7 +142,7 @@ fun BalancePopUpScreen(balanceDetailedViewModel: BalanceDetailedViewModel) { onClick = { balanceDetailedViewModel.noReceiptSelected(true) amountString = "" // Clear the amount - receiptUid = "" // Clear the receipt UID + receiptUid = null // Clear the receipt UID receiptName = "No receipt" // Set the receipt name to "No receipt" receiptExpanded = false }) @@ -173,7 +170,7 @@ fun BalancePopUpScreen(balanceDetailedViewModel: BalanceDetailedViewModel) { modifier = Modifier.padding(8.dp).testTag("editAmount"), value = amountString, onValueChange = { - if (receiptUid == "") { + if (receiptUid == null) { amountString = it balanceDetailedViewModel.checkAmount(amountString) } @@ -182,7 +179,7 @@ fun BalancePopUpScreen(balanceDetailedViewModel: BalanceDetailedViewModel) { keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal), supportingText = { Text(balanceModel.errorAmount ?: "") }, - enabled = receiptUid == "" // Disable editing if receipt is not null + enabled = receiptUid == null // Disable editing if receipt is not null ) } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/budget/BudgetDetailedViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/budget/BudgetDetailedViewModel.kt index 5371e9c8c..8745343fc 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/budget/BudgetDetailedViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/budget/BudgetDetailedViewModel.kt @@ -279,11 +279,7 @@ class BudgetDetailedViewModel( fun setAmount(amount: String) { _uiState.value = _uiState.value.copy( - amountError = - amount.isEmpty() || - amount.toDoubleOrNull() == null || - amount.toDouble() < 0.0 || - isTooLarge(amount)) + amountError = amount.isEmpty() || amount.toDoubleOrNull() == null || isTooLarge(amount)) } fun setDescription(description: String) { diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/budget/BudgetPopUpScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/budget/BudgetPopUpScreen.kt index dc20f6a29..2345c13d8 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/budget/BudgetPopUpScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/accounting/budget/BudgetPopUpScreen.kt @@ -116,7 +116,7 @@ fun BudgetPopUpScreen(budgetViewModel: BudgetDetailedViewModel) { item { OutlinedTextField( singleLine = true, - modifier = Modifier.padding(8.dp), + modifier = Modifier.padding(8.dp).testTag("editAmountBox"), value = amountString, isError = budgetModel.amountError, onValueChange = { diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/ReceiptListScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/ReceiptListScreen.kt index 0f5841b43..bce378aac 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/ReceiptListScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/ReceiptListScreen.kt @@ -24,8 +24,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.github.se.assocify.model.entities.Receipt +import com.github.se.assocify.model.entities.RoleType import com.github.se.assocify.ui.composables.CenteredCircularIndicator import com.github.se.assocify.ui.composables.ErrorMessage +import com.github.se.assocify.ui.composables.PullDownRefreshBox import com.github.se.assocify.ui.util.DateUtil import com.github.se.assocify.ui.util.PriceUtil @@ -45,58 +47,63 @@ fun ReceiptListScreen(viewModel: ReceiptListViewModel) { return } - LazyColumn( - modifier = Modifier.testTag("ReceiptList").fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.Top), - horizontalAlignment = Alignment.CenterHorizontally) { - // Header for the user receipts - item { - Text( - text = "My Receipts", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) - HorizontalDivider() - } + PullDownRefreshBox( + refreshing = viewmodelState.refresh, onRefresh = { viewModel.refreshReceipts() }) { + LazyColumn( + modifier = Modifier.testTag("ReceiptList").fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally) { + // Header for the user receipts + item { + Text( + text = "My Receipts", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) + HorizontalDivider() + } - if (viewmodelState.userReceipts.isNotEmpty()) { - // First list of receipts - viewmodelState.userReceipts.forEach { receipt -> - item { - ReceiptItem(receipt, viewModel) - HorizontalDivider() - } - } - } else { - // Placeholder for empty list - item { - Text( - text = "No receipts found. You can create one!", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(20.dp)) - } - } + if (viewmodelState.userReceipts.isNotEmpty()) { + // First list of receipts + viewmodelState.userReceipts.forEach { receipt -> + item { + ReceiptItem(receipt, viewModel) + HorizontalDivider() + } + } + } else { + // Placeholder for empty list + item { + Text( + text = "No receipts found. You can create one!", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(20.dp)) + } + } - // Global receipts only appear if the user has the permission, - // which is handled in the viewmodel whatsoever - if (viewmodelState.allReceipts.isNotEmpty()) { - // Header for the global receipts - item { - Text( - text = "All Receipts", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) - HorizontalDivider() - } - // Second list of receipts - viewmodelState.allReceipts.forEach { receipt -> - item { - ReceiptItem(receipt, viewModel) - HorizontalDivider() - } - } - } + // Global receipts only appear if the user has the permission, + // which is handled in the viewmodel whatsoever + if (viewmodelState.allReceipts.isNotEmpty() && + (viewmodelState.userCurrentRole.type == RoleType.TREASURY || + viewmodelState.userCurrentRole.type == RoleType.PRESIDENCY)) { + // Header for the global receipts + item { + Text( + text = "All Receipts", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) + HorizontalDivider() + } + // Second list of receipts + viewmodelState.allReceipts.forEach { receipt -> + item { + ReceiptItem(receipt, viewModel) + HorizontalDivider() + } + } + } - item { Spacer(modifier = Modifier.height(80.dp)) } + item { Spacer(modifier = Modifier.height(80.dp)) } + } } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/ReceiptListViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/ReceiptListViewModel.kt index 367074c1e..c5f3f8809 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/ReceiptListViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/ReceiptListViewModel.kt @@ -1,10 +1,16 @@ package com.github.se.assocify.ui.screens.treasury.receiptstab import android.util.Log +import com.github.se.assocify.model.CurrentUser import com.github.se.assocify.model.database.ReceiptAPI +import com.github.se.assocify.model.database.UserAPI +import com.github.se.assocify.model.entities.PermissionRole import com.github.se.assocify.model.entities.Receipt +import com.github.se.assocify.model.entities.RoleType import com.github.se.assocify.navigation.Destination import com.github.se.assocify.navigation.NavigationActions +import com.github.se.assocify.ui.util.SnackbarSystem +import com.github.se.assocify.ui.util.SyncSystem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,34 +21,49 @@ import kotlinx.coroutines.flow.StateFlow */ class ReceiptListViewModel( private val navActions: NavigationActions, - private val receiptsDatabase: ReceiptAPI + private val receiptsDatabase: ReceiptAPI, + private val snackbarSystem: SnackbarSystem, + private val userAPI: UserAPI ) { // ViewModel states private val _uiState: MutableStateFlow = MutableStateFlow(ReceiptUIState()) val uiState: StateFlow - private var loadCounter = 0 + private val loadSystem = + SyncSystem( + { _uiState.value = _uiState.value.copy(loading = false, refresh = false, error = null) }, + { _uiState.value = _uiState.value.copy(loading = false, refresh = false, error = it) }) + + private val refreshSystem = + SyncSystem( + { updateReceipts() }, + { error -> + _uiState.value = _uiState.value.copy(refresh = false) + snackbarSystem.showSnackbar(error) + }) init { - updateReceipts() uiState = _uiState + updateReceipts() } fun updateReceipts() { - loadCounter = 2 + if (!loadSystem.start(3)) return _uiState.value = _uiState.value.copy(loading = true) + getCurrentUserRole() updateUserReceipts() updateAllReceipts() } - private fun endLoading() { - if (--loadCounter == 0) { - _uiState.value = _uiState.value.copy(loading = false, error = null) - } - } + fun refreshReceipts() { + if (!refreshSystem.start(3)) return - private fun loadingError() { - _uiState.value = _uiState.value.copy(loading = false, error = "Error loading receipts") + _uiState.value = _uiState.value.copy(refresh = true) + + // two callbacks ! + receiptsDatabase.updateCaches( + onSuccess = { _, _ -> refreshSystem.end() }, + onFailure = { _, _ -> refreshSystem.end("Error refreshing receipts") }) } /** Update the user's receipts */ @@ -55,17 +76,27 @@ class ReceiptListViewModel( receipts.filter { it.title.contains(_uiState.value.searchQuery, ignoreCase = true) }) - endLoading() + loadSystem.end() }, onFailure = { Log.e("ReceiptListViewModel", "Error fetching user receipts", it) - loadingError() + loadSystem.end("Error loading receipts") }) } + private fun getCurrentUserRole() { + userAPI.getCurrentUserRole( + { role -> + _uiState.value = _uiState.value.copy(userCurrentRole = role) + loadSystem.end() + }, + { + Log.e("ReceiptListViewModel", "Error fetching user role", it) + loadSystem.end("Error loading current user role") + }) + } /** Update all receipts */ private fun updateAllReceipts() { - // TODO : Add a permission check. receiptsDatabase.getAllReceipts( onSuccess = { receipts -> _uiState.value = @@ -74,11 +105,11 @@ class ReceiptListViewModel( receipts.filter { it.title.contains(_uiState.value.searchQuery, ignoreCase = true) }) - endLoading() + loadSystem.end() }, onFailure = { Log.e("ReceiptListViewModel", "Error fetching all receipts", it) - loadingError() + loadSystem.end("Error loading receipts") }) } @@ -107,7 +138,10 @@ class ReceiptListViewModel( data class ReceiptUIState( val loading: Boolean = false, val error: String? = null, + val refresh: Boolean = false, val userReceipts: List = listOf(), val allReceipts: List = listOf(), - val searchQuery: String = "" + val searchQuery: String = "", + val userCurrentRole: PermissionRole = + PermissionRole(CurrentUser.userUid!!, CurrentUser.associationUid!!, RoleType.MEMBER) ) diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/receipt/ReceiptScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/receipt/ReceiptScreen.kt index ab6abab42..56e147f5c 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/receipt/ReceiptScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/receipt/ReceiptScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions 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.outlined.ReceiptLong @@ -51,7 +52,7 @@ import com.github.se.assocify.ui.composables.DatePickerWithDialog import com.github.se.assocify.ui.composables.ErrorMessage import com.github.se.assocify.ui.composables.PhotoSelectionSheet -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun ReceiptScreen(viewModel: ReceiptViewModel) { diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/receipt/ReceiptViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/receipt/ReceiptViewModel.kt index adc9dca2d..025ea9420 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/receipt/ReceiptViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/treasury/receiptstab/receipt/ReceiptViewModel.kt @@ -99,10 +99,6 @@ class ReceiptViewModel { }) } - fun setStatus(status: Status) { - _uiState.value = _uiState.value.copy(status = status) - } - fun setTitle(title: String) { _uiState.value = _uiState.value.copy(title = title) if (title.isEmpty()) { diff --git a/app/src/main/java/com/github/se/assocify/ui/util/SyncSystem.kt b/app/src/main/java/com/github/se/assocify/ui/util/SyncSystem.kt new file mode 100644 index 000000000..50f7c8324 --- /dev/null +++ b/app/src/main/java/com/github/se/assocify/ui/util/SyncSystem.kt @@ -0,0 +1,53 @@ +package com.github.se.assocify.ui.util + +/** + * A system to manage sync operations. This class is used to manage a set of sync operations. It is + * used to ensure that all operations are completed before calling the success callback. If an error + * occurs during any of the operations, the error callback is called and the success callback is not + * called. + * + * In particular useful for loading or refreshing several things at once (asynchronous) + * + * @param onSuccess The callback to call when all operations are completed successfully. + * @param onError The callback to call when an error occurs during any of the operations. + */ +class SyncSystem(private val onSuccess: () -> Unit, private val onError: (String) -> Unit) { + private var counter = 0 + private var errorOccurred = false + + /** + * Start a new set of sync operation. + * + * @param count The number of operations that will be performed. + * @return True if the op set was started, false if there is already an op set in progress. + */ + fun start(count: Int): Boolean { + return if (counter == 0) { + counter = count + errorOccurred = false + true + } else { + false + } + } + + /** + * End a sync operation. + * + * @param error An error message if an error occured. + */ + fun end(error: String? = null) { + if (errorOccurred) return + + if (error != null) { + errorOccurred = true + counter = 0 + onError(error) + } else { + counter -= 1 + if (counter == 0) { + onSuccess() + } + } + } +} diff --git a/app/src/test/java/com/github/se/assocify/model/database/EventAPITest.kt b/app/src/test/java/com/github/se/assocify/model/database/EventAPITest.kt index 8c87ce875..8466c9ce6 100644 --- a/app/src/test/java/com/github/se/assocify/model/database/EventAPITest.kt +++ b/app/src/test/java/com/github/se/assocify/model/database/EventAPITest.kt @@ -162,14 +162,7 @@ class EventAPITest { .trimIndent() eventAPI.addEvent( - Event( - uid = uuid1.toString(), - name = "Test Event", - description = "Test Description", - startDate = currentTime, - endDate = currentTime, - guestsOrArtists = "Test Guest", - location = "Test Location"), + Event(uid = uuid1.toString(), name = "Test Event", description = "Test Description"), onSuccess) { fail("should not fail") } @@ -210,15 +203,7 @@ class EventAPITest { verify(timeout = 1000) { successMockCache(any()) } eventAPI.updateEvent( - Event( - uid = "$uuid1", - name = "Test Event", - description = "Test Description", - startDate = currentTime, - endDate = currentTime, - guestsOrArtists = "Test Guest", - location = "Test Location"), - onSuccess) { + Event(uid = "$uuid1", name = "Test Event", description = "Test Description"), onSuccess) { fail("Should not fail") } diff --git a/app/src/test/java/com/github/se/assocify/navigation/NavigationActionsTest.kt b/app/src/test/java/com/github/se/assocify/navigation/NavigationActionsTest.kt index b8abac65b..c9f8d0162 100644 --- a/app/src/test/java/com/github/se/assocify/navigation/NavigationActionsTest.kt +++ b/app/src/test/java/com/github/se/assocify/navigation/NavigationActionsTest.kt @@ -43,7 +43,9 @@ class NavigationActionsTest { fun `onLogin navigates to Home when user exists`() { navigationActions.onLogin(true) - verify { navController.navigate(Destination.Home.route, any<(NavOptionsBuilder) -> Unit>()) } + verify { + navController.navigate(Destination.Treasury.route, any<(NavOptionsBuilder) -> Unit>()) + } } @Test