From 46da4729cc25de4858448df31bcc209eba93964a Mon Sep 17 00:00:00 2001 From: Jonas Sulzer Date: Thu, 30 May 2024 22:15:57 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20delete=20unused?= =?UTF-8?q?=20viewmodel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonas Sulzer --- .../viewmodels/user/UserProfileViewModel.kt | 51 ----------------- .../user/UserProfileViewModelTest.kt | 55 ------------------- 2 files changed, 106 deletions(-) delete mode 100644 app/src/main/java/com/github/swent/echo/viewmodels/user/UserProfileViewModel.kt delete mode 100644 app/src/test/java/com/github/swent/echo/viewmodels/user/UserProfileViewModelTest.kt diff --git a/app/src/main/java/com/github/swent/echo/viewmodels/user/UserProfileViewModel.kt b/app/src/main/java/com/github/swent/echo/viewmodels/user/UserProfileViewModel.kt deleted file mode 100644 index 445a36be7..000000000 --- a/app/src/main/java/com/github/swent/echo/viewmodels/user/UserProfileViewModel.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.swent.echo.viewmodels.user - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.swent.echo.authentication.AuthenticationService -import com.github.swent.echo.data.model.UserProfile -import com.github.swent.echo.data.repository.Repository -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -/** - * This class is the viewmodel of a user profile. - * - * @param repository a repository - * @param authenticationService an authentication service - */ -@HiltViewModel -class UserProfileViewModel -@Inject -constructor( - private val repository: Repository, - private val authenticationService: AuthenticationService, -) : ViewModel() { - private val _userProfile = MutableStateFlow(UserProfile.EMPTY) - val userProfile = _userProfile.asStateFlow() - - /** Fetch the user profile from the repository. */ - init { - viewModelScope.launch { - assert(authenticationService.userIsLoggedIn()) - _userProfile.value = - repository.getUserProfile(authenticationService.getCurrentUserID()!!)!! - } - } - - /** - * Change the user profile in the repository. - * - * @param newUserProfile the new user profile - */ - fun setUserProfile(newUserProfile: UserProfile) { - viewModelScope.launch { - repository.setUserProfile(newUserProfile) - _userProfile.value = - repository.getUserProfile(authenticationService.getCurrentUserID()!!)!! - } - } -} diff --git a/app/src/test/java/com/github/swent/echo/viewmodels/user/UserProfileViewModelTest.kt b/app/src/test/java/com/github/swent/echo/viewmodels/user/UserProfileViewModelTest.kt deleted file mode 100644 index aaf3b5506..000000000 --- a/app/src/test/java/com/github/swent/echo/viewmodels/user/UserProfileViewModelTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.github.swent.echo.viewmodels.user - -import com.github.swent.echo.authentication.AuthenticationService -import com.github.swent.echo.data.model.UserProfile -import com.github.swent.echo.data.repository.Repository -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.setMain -import org.junit.Before -import org.junit.Test - -class UserProfileViewModelTest { - - private val mockedAuthenticationService = mockk(relaxed = true) - private val mockedRepository = mockk(relaxed = true) - private val userId = "user-id" - private val userProfile = - UserProfile(userId, "test name", null, null, setOf(), setOf(), setOf()) - private val scheduler = TestCoroutineScheduler() - private lateinit var userProfileViewModel: UserProfileViewModel - - @Before - fun init() { - every { mockedAuthenticationService.userIsLoggedIn() } returns true - every { mockedAuthenticationService.getCurrentUserID() } returns userId - coEvery { mockedRepository.getUserProfile(any()) } returns userProfile - Dispatchers.setMain(StandardTestDispatcher(scheduler)) - runBlocking { - userProfileViewModel = - UserProfileViewModel(mockedRepository, mockedAuthenticationService) - } - scheduler.runCurrent() - } - - @Test - fun userProfileIsCorrect() { - assertEquals(userProfile, userProfileViewModel.userProfile.value) - } - - @Test - fun changedUserProfileIsChanged() { - val newUserProfile = userProfile.copy(name = "changed name") - userProfileViewModel.setUserProfile(newUserProfile) - scheduler.runCurrent() - coVerify { mockedRepository.setUserProfile(any()) } - assertEquals(userProfile, userProfileViewModel.userProfile.value) - } -} From be31225666a1f3f02bc8a88e56168559692a86c9 Mon Sep 17 00:00:00 2001 From: Jonas Sulzer Date: Thu, 30 May 2024 22:18:59 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=93=A6=20NEW:=20provide=20feedback=20?= =?UTF-8?q?about=20edge=20case=20network=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonas Sulzer --- .../compose/association/AssociationScreen.kt | 27 ++++++++++++++-- .../compose/authentication/CreateProfile.kt | 19 +++++++++--- .../compose/components/JoinEventButton.kt | 29 +++++++++++++++++ .../association/AssociationViewModel.kt | 26 +++++++++++++++- .../echo/viewmodels/event/EventViewModel.kt | 21 ++++++++----- .../viewmodels/myevents/MyEventsViewModel.kt | 31 +++++++++++++++---- app/src/main/res/values/strings.xml | 4 +++ 7 files changed, 137 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/github/swent/echo/compose/association/AssociationScreen.kt b/app/src/main/java/com/github/swent/echo/compose/association/AssociationScreen.kt index 9a37e61d0..c2454e06b 100644 --- a/app/src/main/java/com/github/swent/echo/compose/association/AssociationScreen.kt +++ b/app/src/main/java/com/github/swent/echo/compose/association/AssociationScreen.kt @@ -6,11 +6,17 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -21,6 +27,7 @@ import com.github.swent.echo.compose.event.EventTitleAndBackButton import com.github.swent.echo.data.model.Association import com.github.swent.echo.ui.navigation.NavigationActions import com.github.swent.echo.ui.navigation.Routes +import com.github.swent.echo.viewmodels.association.AssociationStatus import com.github.swent.echo.viewmodels.association.AssociationViewModel /** @@ -33,10 +40,13 @@ fun AssociationScreen( AssociationViewModel, // The ViewModel that provides the data for the screen navActions: NavigationActions // The actions that can be performed for navigation ) { - // Collect the state of followed associations, committee associations, and all associations + // Collect the state of followed associations, committee associations all associations, + // snackbarHostState and status val followedAssociations by associationViewModel.followedAssociations.collectAsState() val committeeAssociations by associationViewModel.committeeAssociations.collectAsState() val showAllAssociations by associationViewModel.showAllAssociations.collectAsState() + val snackbarHostState by remember { mutableStateOf(SnackbarHostState()) } + val status by associationViewModel.status.collectAsState() // Create a list of pages for the Pager val pages = @@ -59,6 +69,18 @@ fun AssociationScreen( // Define the space between the search bar and the pages val spaceBetweenSearchAndPages = 8.dp + // Display a Snackbar if we are in Error state + val errorMessage = + LocalContext.current.resources.getString( + R.string.association_subscription_error_network_failure + ) + if (status is AssociationStatus.Error) { + LaunchedEffect(status) { + snackbarHostState.showSnackbar(errorMessage, withDismissAction = true) + associationViewModel.resetErrorState() + } + } + // Scaffold provides a framework for material design surfaces Scaffold( topBar = { @@ -73,7 +95,8 @@ fun AssociationScreen( } } }, - modifier = Modifier.fillMaxSize().testTag("association_screen") + modifier = Modifier.fillMaxSize().testTag("association_screen"), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { paddingValues -> Column( modifier = Modifier.padding(paddingValues), diff --git a/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt b/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt index 73f56d7a1..68cf9012c 100644 --- a/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt +++ b/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -51,7 +52,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SmallFloatingActionButton @@ -98,6 +98,7 @@ import com.github.swent.echo.data.model.SectionEPFL import com.github.swent.echo.data.model.Semester import com.github.swent.echo.data.model.SemesterEPFL import com.github.swent.echo.data.model.Tag +import com.github.swent.echo.data.repository.RepositoryStoreWhileNoInternetException import com.github.swent.echo.ui.navigation.NavigationActions import com.github.swent.echo.ui.navigation.Routes import com.github.swent.echo.viewmodels.authentication.CreateProfileState @@ -341,9 +342,10 @@ fun ProfileCreationUI( Spacer(modifier = modifier.weight(1f)) val errorLN = stringResource(R.string.profile_creation_empty_LN) val errorFN = stringResource(R.string.profile_creation_empty_FN) + val errorNetwork = stringResource(R.string.profile_creation_error_network_failure) // Save button - OutlinedButton( + Button( onClick = { if (firstName.isBlank() || lastName.isBlank()) { scope.launch { @@ -360,8 +362,17 @@ fun ProfileCreationUI( } } } else { - onSave(firstName, lastName) - navAction.navigateTo(Routes.MAP) + try { + onSave(firstName, lastName) + navAction.navigateTo(Routes.MAP) + } catch (e: RepositoryStoreWhileNoInternetException) { + scope.launch { + snackbarHostState.showSnackbar( + errorNetwork, + withDismissAction = true + ) + } + } } }, modifier = modifier.fillMaxWidth().testTag("Save"), diff --git a/app/src/main/java/com/github/swent/echo/compose/components/JoinEventButton.kt b/app/src/main/java/com/github/swent/echo/compose/components/JoinEventButton.kt index ae1c152fd..db90fd819 100644 --- a/app/src/main/java/com/github/swent/echo/compose/components/JoinEventButton.kt +++ b/app/src/main/java/com/github/swent/echo/compose/components/JoinEventButton.kt @@ -1,17 +1,27 @@ package com.github.swent.echo.compose.components +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.material3.Button +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.hilt.navigation.compose.hiltViewModel import com.github.swent.echo.R import com.github.swent.echo.data.model.Event +import com.github.swent.echo.viewmodels.myevents.MyEventStatus import com.github.swent.echo.viewmodels.myevents.MyEventsViewModel /** @@ -30,6 +40,19 @@ fun JoinEventButton(event: Event, isOnline: Boolean, buttonWidth: Dp, refreshEve val myEventsViewModel: MyEventsViewModel = hiltViewModel() // Observe the list of events that the user has joined. val joinedEvents by myEventsViewModel.joinedEvents.collectAsState() + + // Snackbar for displaying errors + val snackbarHostState by remember { mutableStateOf(SnackbarHostState()) } + val status by myEventsViewModel.status.collectAsState() + val errorMessage = + LocalContext.current.resources.getString(R.string.event_join_error_network_failure) + if (status is MyEventStatus.Error) { + LaunchedEffect(status) { + snackbarHostState.showSnackbar(errorMessage, withDismissAction = true) + myEventsViewModel.resetErrorState() + } + } + // Create a button for joining or leaving the event. Button( // The button is enabled if the user is online and the event is not full. @@ -51,4 +74,10 @@ fun JoinEventButton(event: Event, isOnline: Boolean, buttonWidth: Dp, refreshEve stringResource(id = R.string.list_drawer_join_event) ) } + Box { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } } diff --git a/app/src/main/java/com/github/swent/echo/viewmodels/association/AssociationViewModel.kt b/app/src/main/java/com/github/swent/echo/viewmodels/association/AssociationViewModel.kt index 5ea38a571..2148f50fc 100644 --- a/app/src/main/java/com/github/swent/echo/viewmodels/association/AssociationViewModel.kt +++ b/app/src/main/java/com/github/swent/echo/viewmodels/association/AssociationViewModel.kt @@ -8,6 +8,7 @@ import com.github.swent.echo.data.model.Association import com.github.swent.echo.data.model.Event import com.github.swent.echo.data.model.toAssociationHeader import com.github.swent.echo.data.repository.Repository +import com.github.swent.echo.data.repository.RepositoryStoreWhileNoInternetException import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -49,6 +50,8 @@ constructor( val searched = _searched.asStateFlow() // Online status val isOnline = networkService.isOnline + private val _status = MutableStateFlow(AssociationStatus.Okay) + val status = _status.asStateFlow() // Initialize the ViewModel init { @@ -70,10 +73,14 @@ constructor( // Handle follow/unfollow association fun onFollowAssociationChanged(association: Association) { + _status.value = AssociationStatus.Okay + val revertAction: () -> Unit if (_followedAssociations.value.contains(association)) { _followedAssociations.value -= association + revertAction = { _followedAssociations.value += association } } else { _followedAssociations.value += association + revertAction = { _followedAssociations.value -= association } } viewModelScope.launch { val userProfile = repository.getUserProfile(authenticationService.getCurrentUserID()!!) @@ -82,10 +89,20 @@ constructor( associationsSubscriptions = _followedAssociations.value.toAssociationHeader().toSet() ) - repository.setUserProfile(updatedProfile!!) + try { + repository.setUserProfile(updatedProfile!!) + } catch (e: RepositoryStoreWhileNoInternetException) { + _status.value = AssociationStatus.Error + revertAction() + } } } + // Reset error state + fun resetErrorState() { + _status.value = AssociationStatus.Okay + } + // Set searched term fun setSearched(searched: String) { _searched.value = searched @@ -121,3 +138,10 @@ constructor( viewModelScope.launch { allEvents = repository.getAllEvents() } } } + +// Status of the Association screen/viewmodel +sealed class AssociationStatus { + data object Okay : AssociationStatus() + + data object Error : AssociationStatus() +} diff --git a/app/src/main/java/com/github/swent/echo/viewmodels/event/EventViewModel.kt b/app/src/main/java/com/github/swent/echo/viewmodels/event/EventViewModel.kt index f2b2fc8aa..f306b239b 100644 --- a/app/src/main/java/com/github/swent/echo/viewmodels/event/EventViewModel.kt +++ b/app/src/main/java/com/github/swent/echo/viewmodels/event/EventViewModel.kt @@ -13,6 +13,7 @@ import com.github.swent.echo.data.model.EventCreator import com.github.swent.echo.data.model.Location import com.github.swent.echo.data.model.Tag import com.github.swent.echo.data.repository.Repository +import com.github.swent.echo.data.repository.RepositoryStoreWhileNoInternetException import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -141,17 +142,23 @@ constructor( } else if (_status.value == EventStatus.Saved) { Log.w("save event", "trying to save the event but it's already saved") } else { + val eventStatusBeforeSaving = _status.value if (eventIsValid()) { _status.value = EventStatus.Saving viewModelScope.launch { - if (isEventNew.value) { - val eventId = repository.createEvent(event.value) - _event.value = event.value.copy(eventId = eventId) - } else { - repository.setEvent(event.value) + try { + if (isEventNew.value) { + val eventId = repository.createEvent(event.value) + _event.value = event.value.copy(eventId = eventId) + } else { + repository.setEvent(event.value) + } + _isEventNew.value = false + _status.value = EventStatus.Saved + } catch (e: RepositoryStoreWhileNoInternetException) { + _status.value = + EventStatus.Error(R.string.event_creation_error_network_failure) } - _isEventNew.value = false - _status.value = EventStatus.Saved } } } diff --git a/app/src/main/java/com/github/swent/echo/viewmodels/myevents/MyEventsViewModel.kt b/app/src/main/java/com/github/swent/echo/viewmodels/myevents/MyEventsViewModel.kt index 9569db106..5724590f4 100644 --- a/app/src/main/java/com/github/swent/echo/viewmodels/myevents/MyEventsViewModel.kt +++ b/app/src/main/java/com/github/swent/echo/viewmodels/myevents/MyEventsViewModel.kt @@ -6,6 +6,7 @@ import com.github.swent.echo.authentication.AuthenticationService import com.github.swent.echo.connectivity.NetworkService import com.github.swent.echo.data.model.Event import com.github.swent.echo.data.repository.Repository +import com.github.swent.echo.data.repository.RepositoryStoreWhileNoInternetException import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -31,6 +32,8 @@ constructor( val createdEvents = _createdEvents.asStateFlow() // Online status val isOnline = networkService.isOnline + private val _status = MutableStateFlow(MyEventStatus.Okay) + val status = _status.asStateFlow() // Initialize the ViewModel init { @@ -43,16 +46,25 @@ constructor( // Handle join/leave event fun joinOrLeaveEvent(event: Event, onFinished: () -> Unit) { viewModelScope.launch { - if (_joinedEvents.value.map { it.eventId }.contains(event.eventId)) { - repository.leaveEvent(user, event) - } else { - repository.joinEvent(user, event) + try { + if (_joinedEvents.value.map { it.eventId }.contains(event.eventId)) { + repository.leaveEvent(user, event) + } else { + repository.joinEvent(user, event) + } + _joinedEvents.value = repository.getJoinedEvents(user) + onFinished() + } catch (e: RepositoryStoreWhileNoInternetException) { + _status.value = MyEventStatus.Error } - _joinedEvents.value = repository.getJoinedEvents(user) - onFinished() } } + // Reset error state + fun resetErrorState() { + _status.value = MyEventStatus.Okay + } + // Refresh events fun refreshEvents() { viewModelScope.launch { @@ -61,3 +73,10 @@ constructor( } } } + +// Status of the Event joining viewmodel +sealed class MyEventStatus { + data object Okay : MyEventStatus() + + data object Error : MyEventStatus() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22cda27e4..a14ad1b49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ The end date is before the start date The title is empty The maximum number of participants is too low + Network problem while saving event. Please retry. EPFL Section Class @@ -87,6 +88,7 @@ Edit picture Delete picture Adjust the zoom + Network problem while saving profile. Please retry. Event successfully joined! Modify Event Event Status @@ -106,6 +108,7 @@ You unfollowed  Undo Your association memberships + Network problem while subscribing or unsubscribing to association. Please retry. Joined Events Created Events Unfollow @@ -134,5 +137,6 @@ Password must be at least 8 characters Passwords do not match Contact : + Network problem while joining or leaving event. Please retry. From 4b709ae53bfe63cb178b519610eda3c9c7958f1b Mon Sep 17 00:00:00 2001 From: Jonas Sulzer Date: Thu, 30 May 2024 22:25:11 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=93=A6=20NEW:=20branch=20event=20dele?= =?UTF-8?q?tion=20button=20on=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonas Sulzer --- .../github/swent/echo/compose/event/EventScreen.kt | 3 +-- .../swent/echo/viewmodels/event/EventViewModel.kt | 12 ++++++++++++ app/src/main/res/values/strings.xml | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/swent/echo/compose/event/EventScreen.kt b/app/src/main/java/com/github/swent/echo/compose/event/EventScreen.kt index 035f98db1..6ff5a34fc 100644 --- a/app/src/main/java/com/github/swent/echo/compose/event/EventScreen.kt +++ b/app/src/main/java/com/github/swent/echo/compose/event/EventScreen.kt @@ -103,8 +103,7 @@ fun EventScreen( ) { if (canDelete) { DeleteEventButton(enabled = isOnline) { - // TODO: delete the event in the repository - onEventDeleted() + eventViewModel.deleteEvent(onEventDeleted) } } OutlinedButton( diff --git a/app/src/main/java/com/github/swent/echo/viewmodels/event/EventViewModel.kt b/app/src/main/java/com/github/swent/echo/viewmodels/event/EventViewModel.kt index f306b239b..a4babc571 100644 --- a/app/src/main/java/com/github/swent/echo/viewmodels/event/EventViewModel.kt +++ b/app/src/main/java/com/github/swent/echo/viewmodels/event/EventViewModel.kt @@ -164,6 +164,18 @@ constructor( } } + // delete the current event in the repository + fun deleteEvent(onEventDeleted: () -> Unit) { + viewModelScope.launch { + try { + repository.deleteEvent(event.value) + onEventDeleted() + } catch (e: RepositoryStoreWhileNoInternetException) { + _status.value = EventStatus.Error(R.string.event_creation_error_network_failure) + } + } + } + /** Change event status from error to modified. */ fun dismissError() { if (_status.value is EventStatus.Error) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a14ad1b49..6b7eeffba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,7 +49,7 @@ The end date is before the start date The title is empty The maximum number of participants is too low - Network problem while saving event. Please retry. + Network problem while saving or deleting event. Please retry. EPFL Section Class From e7052c0f94b04c44b6de28583fa2ba18ff778bd1 Mon Sep 17 00:00:00 2001 From: Jonas Sulzer Date: Fri, 31 May 2024 10:46:10 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20alignment=20of=20snac?= =?UTF-8?q?kbar=20for=20join/leave=20event=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonas Sulzer --- .../compose/components/JoinEventButton.kt | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/github/swent/echo/compose/components/JoinEventButton.kt b/app/src/main/java/com/github/swent/echo/compose/components/JoinEventButton.kt index db90fd819..c63d48dc1 100644 --- a/app/src/main/java/com/github/swent/echo/compose/components/JoinEventButton.kt +++ b/app/src/main/java/com/github/swent/echo/compose/components/JoinEventButton.kt @@ -53,31 +53,29 @@ fun JoinEventButton(event: Event, isOnline: Boolean, buttonWidth: Dp, refreshEve } } - // Create a button for joining or leaving the event. - Button( - // The button is enabled if the user is online and the event is not full. - enabled = isOnline && (event.participantCount < event.maxParticipants), - // When the button is clicked, the user joins or leaves the event. - onClick = { myEventsViewModel.joinOrLeaveEvent(event, refreshEvents) }, - // Set the width of the button and a test tag for testing purposes. - modifier = - androidx.compose.ui.Modifier.width(buttonWidth) - .testTag("list_join_event_${event.eventId}") - ) { - // The text of the button depends on whether the user has joined the event. - Text( - if (joinedEvents.map { it.eventId }.contains(event.eventId)) - // If the user has joined the event, the button says "Leave". - stringResource(id = R.string.list_drawer_leave_event) - else - // If the user has not joined the event, the button says "Join". - stringResource(id = R.string.list_drawer_join_event) - ) - } Box { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter) - ) + // Create a button for joining or leaving the event. + Button( + // The button is enabled if the user is online and the event is not full. + enabled = isOnline && (event.participantCount < event.maxParticipants), + // When the button is clicked, the user joins or leaves the event. + onClick = { myEventsViewModel.joinOrLeaveEvent(event, refreshEvents) }, + // Set the width of the button and a test tag for testing purposes. + modifier = + androidx.compose.ui.Modifier.width(buttonWidth) + .align(Alignment.Center) + .testTag("list_join_event_${event.eventId}") + ) { + // The text of the button depends on whether the user has joined the event. + Text( + if (joinedEvents.map { it.eventId }.contains(event.eventId)) + // If the user has joined the event, the button says "Leave". + stringResource(id = R.string.list_drawer_leave_event) + else + // If the user has not joined the event, the button says "Join". + stringResource(id = R.string.list_drawer_join_event) + ) + } + SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.Center)) } } From f2a85315e677162b32bf767c43dc3834e7993606 Mon Sep 17 00:00:00 2001 From: Jonas Sulzer Date: Fri, 31 May 2024 16:30:27 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=85=20TEST:=20network=20issue=20feedb?= =?UTF-8?q?ack=20ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonas Sulzer --- .../compose/authentication/CreateProfile.kt | 7 +++- .../authentication/CreateProfileTest.kt | 34 +++++++++++++++++++ .../association/AssociationViewModelTest.kt | 9 +++++ .../viewmodels/event/EventViewModelTest.kt | 16 +++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt b/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt index 68cf9012c..93cbae458 100644 --- a/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt +++ b/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt @@ -244,7 +244,12 @@ fun ProfileCreationUI( modifier = Modifier.fillMaxWidth() ) }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.testTag("profile-creation-snackbar") + ) + } ) { innerPadding -> Box( modifier = modifier.fillMaxSize().padding(innerPadding).testTag("profile-creation"), diff --git a/app/src/test/java/com/github/swent/echo/compose/authentication/CreateProfileTest.kt b/app/src/test/java/com/github/swent/echo/compose/authentication/CreateProfileTest.kt index 4c6e5791f..07863bf2b 100644 --- a/app/src/test/java/com/github/swent/echo/compose/authentication/CreateProfileTest.kt +++ b/app/src/test/java/com/github/swent/echo/compose/authentication/CreateProfileTest.kt @@ -18,8 +18,14 @@ import com.github.swent.echo.connectivity.NetworkService import com.github.swent.echo.data.model.SectionEPFL import com.github.swent.echo.data.model.SemesterEPFL import com.github.swent.echo.data.model.Tag +import com.github.swent.echo.data.model.UserProfile +import com.github.swent.echo.data.repository.RepositoryImpl +import com.github.swent.echo.data.repository.RepositoryStoreWhileNoInternetException import com.github.swent.echo.data.repository.SimpleRepository +import com.github.swent.echo.ui.navigation.NavigationActions import com.github.swent.echo.viewmodels.authentication.CreateProfileViewModel +import com.github.swent.echo.viewmodels.tag.TagViewModel +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import java.io.ByteArrayOutputStream @@ -84,6 +90,34 @@ class CreateProfileTest { composeTestRule.onNodeWithTag("BA2").assertExists() } + @Test + fun `profile creation while network error shows error snackbar`() { + val authenticationService: AuthenticationService = mockk(relaxed = true) + val mockedRepository = mockk() + val mockedNetworkService = mockk() + every { mockedNetworkService.isOnline } returns MutableStateFlow(true) + coEvery { mockedRepository.getUserProfile(any()) } returns UserProfile.EMPTY + coEvery { mockedRepository.getUserProfilePicture(any()) } returns ByteArray(0) + + val viewModel = + CreateProfileViewModel(authenticationService, mockedRepository, mockedNetworkService) + + val mockedNavAction = mockk() + val mockedTagViewModel = mockk() + + composeTestRule.setContent { + ProfileCreationScreen( + viewModel = viewModel, + navAction = mockedNavAction, + tagviewModel = mockedTagViewModel + ) + } + coEvery { mockedRepository.setUserProfile(any()) } throws + RepositoryStoreWhileNoInternetException("test") + composeTestRule.onNodeWithTag("Save").performClick() + composeTestRule.onNodeWithTag("profile-creation-snackbar").assertIsDisplayed() + } + @Test fun profileCreationPictureIsCorrect() { val picture = Bitmap.createBitmap(1000, 1000, Bitmap.Config.ARGB_8888) diff --git a/app/src/test/java/com/github/swent/echo/viewmodels/association/AssociationViewModelTest.kt b/app/src/test/java/com/github/swent/echo/viewmodels/association/AssociationViewModelTest.kt index 8641e22d5..5e4007b37 100644 --- a/app/src/test/java/com/github/swent/echo/viewmodels/association/AssociationViewModelTest.kt +++ b/app/src/test/java/com/github/swent/echo/viewmodels/association/AssociationViewModelTest.kt @@ -11,6 +11,7 @@ import com.github.swent.echo.data.model.Tag import com.github.swent.echo.data.model.UserProfile import com.github.swent.echo.data.model.toAssociationHeader import com.github.swent.echo.data.repository.Repository +import com.github.swent.echo.data.repository.RepositoryStoreWhileNoInternetException import com.github.swent.echo.fakes.FakeAuthenticationService import io.mockk.coEvery import io.mockk.coVerify @@ -145,6 +146,14 @@ class AssociationViewModelTest { assert(associationViewModel.followedAssociations.value.contains(associationList[2])) associationViewModel.onFollowAssociationChanged(associationList[2]) assert(!associationViewModel.followedAssociations.value.contains(associationList[2])) + coEvery { mockedRepository.setUserProfile(any()) } throws + RepositoryStoreWhileNoInternetException("test") + associationViewModel.onFollowAssociationChanged(associationList[2]) + scheduler.runCurrent() + assert(!associationViewModel.followedAssociations.value.contains(associationList[2])) + assert(associationViewModel.status.value is AssociationStatus.Error) + associationViewModel.resetErrorState() + assert(associationViewModel.status.value is AssociationStatus.Okay) } @Test diff --git a/app/src/test/java/com/github/swent/echo/viewmodels/event/EventViewModelTest.kt b/app/src/test/java/com/github/swent/echo/viewmodels/event/EventViewModelTest.kt index a048ca9a2..44727f2c7 100644 --- a/app/src/test/java/com/github/swent/echo/viewmodels/event/EventViewModelTest.kt +++ b/app/src/test/java/com/github/swent/echo/viewmodels/event/EventViewModelTest.kt @@ -12,6 +12,7 @@ import com.github.swent.echo.data.model.Location import com.github.swent.echo.data.model.Tag import com.github.swent.echo.data.model.UserProfile import com.github.swent.echo.data.repository.Repository +import com.github.swent.echo.data.repository.RepositoryStoreWhileNoInternetException import com.github.swent.echo.fakes.FakeAuthenticationService import io.mockk.coEvery import io.mockk.coVerify @@ -120,6 +121,7 @@ class EventViewModelTest { eventViewModel.setEvent(TEST_EVENT) eventViewModel.saveEvent() eventViewModel.saveEvent() + scheduler.runCurrent() verify { Log.w(any(), any() as String) } } @@ -132,6 +134,7 @@ class EventViewModelTest { ) eventViewModel.setEvent(event) eventViewModel.saveEvent() + scheduler.runCurrent() assertTrue(eventViewModel.status.value is EventStatus.Error) } @@ -140,6 +143,19 @@ class EventViewModelTest { val event = TEST_EVENT.copy(title = " ") eventViewModel.setEvent(event) eventViewModel.saveEvent() + scheduler.runCurrent() + assertTrue(eventViewModel.status.value is EventStatus.Error) + } + + @Test + fun saveWhileNetworkErrorChangesStatusToError() { + coEvery { mockedRepository.createEvent(TEST_EVENT) } throws + RepositoryStoreWhileNoInternetException("test") + coEvery { mockedRepository.setEvent(TEST_EVENT) } throws + RepositoryStoreWhileNoInternetException("test") + eventViewModel.setEvent(TEST_EVENT) + eventViewModel.saveEvent() + scheduler.runCurrent() assertTrue(eventViewModel.status.value is EventStatus.Error) }