Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide feedback in UI about network issues and allow for retrying #371

Merged
merged 5 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand All @@ -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 =
Expand All @@ -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 = {
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -243,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"),
Expand Down Expand Up @@ -341,9 +347,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 {
Expand All @@ -360,8 +367,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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -30,25 +40,42 @@ 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()
// 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)
)

// 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()
}
}

Box {
// 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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ fun EventScreen(
) {
if (canDelete) {
DeleteEventButton(enabled = isOnline) {
// TODO: delete the event in the repository
onEventDeleted()
eventViewModel.deleteEvent(onEventDeleted)
}
}
OutlinedButton(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +50,8 @@ constructor(
val searched = _searched.asStateFlow()
// Online status
val isOnline = networkService.isOnline
private val _status = MutableStateFlow<AssociationStatus>(AssociationStatus.Okay)
val status = _status.asStateFlow()

// Initialize the ViewModel
init {
Expand All @@ -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()!!)
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,22 +142,40 @@ 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
}
}
}
}

// 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) {
Expand Down
Loading