diff --git a/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileMembersScreenTest.kt b/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileMembersScreenTest.kt index 818c132c1..2e3a8ed11 100644 --- a/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileMembersScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/assocify/screens/profile/ProfileMembersScreenTest.kt @@ -1,11 +1,11 @@ package com.github.se.assocify.screens.profile import android.util.Log -import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -14,6 +14,7 @@ import androidx.compose.ui.test.performScrollToNode import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.se.assocify.model.CurrentUser import com.github.se.assocify.model.database.AssociationAPI +import com.github.se.assocify.model.database.UserAPI import com.github.se.assocify.model.entities.Association import com.github.se.assocify.model.entities.AssociationMember import com.github.se.assocify.model.entities.PermissionRole @@ -41,7 +42,7 @@ class ProfileMembersScreenTest : private val navActions = mockk() private var goBack = false - private val userList = + private var userList = listOf( User("1", "Sarah"), User("2", "veryveryveryverylooooooooooooooooooooongnameeeeeeeeee"), @@ -56,14 +57,29 @@ class ProfileMembersScreenTest : User("11", "Ivy"), ) + private var userRole: MutableMap = + mutableMapOf( + "1" to RoleType.PRESIDENCY, + "2" to RoleType.TREASURY, + "3" to RoleType.MEMBER, + "4" to RoleType.MEMBER, + "5" to RoleType.MEMBER, + "6" to RoleType.MEMBER, + "7" to RoleType.MEMBER, + "8" to RoleType.MEMBER, + "9" to RoleType.MEMBER, + "10" to RoleType.MEMBER, + "11" to RoleType.MEMBER, + ) + private val applicantList = userList.take(2) - private val assoMembers: List = + private var assoMembers: List = userList.map { AssociationMember( it, Association("a", "assoName", "", LocalDate.EPOCH), - PermissionRole("r", "a", RoleType.MEMBER)) + PermissionRole("r", "a", userRole[it.uid]!!)) } private val associationAPI = @@ -76,6 +92,27 @@ class ProfileMembersScreenTest : { secondArg<(List) -> Unit>().invoke(assoMembers) } + every { updateCache(any(), any()) } answers + { + firstArg<(Map) -> Unit>() + .invoke(mapOf("a" to Association("a", "assoName", "", LocalDate.EPOCH))) + } + } + + private val userAPI = + mockk { + every { removeUserFromAssociation(any(), any(), any(), any()) } answers + { + assoMembers = assoMembers.filter { it.user.uid != firstArg() } + val onSuccess = thirdArg<() -> Unit>() + onSuccess() + } + every { changeRoleOfUser(any(), any(), any(), any(), any()) } answers + { + userRole[firstArg()] = thirdArg() + val onSuccess = arg<() -> Unit>(3) + onSuccess() + } } @Before @@ -87,7 +124,7 @@ class ProfileMembersScreenTest : composeTestRule.setContent { ProfileMembersScreen( - navActions = navActions, ProfileMembersViewModel(navActions, associationAPI)) + navActions = navActions, ProfileMembersViewModel(associationAPI, userAPI)) } } @@ -96,11 +133,6 @@ class ProfileMembersScreenTest : with(composeTestRule) { onNodeWithTag("Members Screen").assertIsDisplayed() - onNodeWithText("New requests").assertIsDisplayed() - applicantList.forEach { onNodeWithTag("applicantCard-${it.uid}").assertIsDisplayed() } - onAllNodesWithTag("rejectButton").assertCountEquals(applicantList.size) - onAllNodesWithTag("acceptButton").assertCountEquals(applicantList.size) - onNodeWithText("Current members").performScrollTo().assertIsDisplayed() assoMembers.forEach { Log.e("assoMembers", it.user.uid) @@ -110,6 +142,43 @@ class ProfileMembersScreenTest : } } + @Test + fun editMember() { + with(composeTestRule) { + val member = assoMembers[0] + val originalRole = member.role.type.name + onNodeWithTag("memberItem-${member.user.uid}").assertTextContains(originalRole) + onNodeWithTag("editButton-0").performClick() + onNodeWithText("Change ${member.user.name}'s role ?").assertIsDisplayed() + onNodeWithTag("role-${originalRole}").assertIsSelected() + onNodeWithTag("role-${RoleType.PRESIDENCY.name}").performClick() + onNodeWithTag("confirmButton").performClick() + onNodeWithTag("memberItem-${member.user.uid}").assertTextContains(RoleType.PRESIDENCY.name) + onNodeWithTag("editButton-0").performClick() + onNodeWithTag("role-${RoleType.PRESIDENCY.name}").assertIsSelected() + onNodeWithTag("role-${originalRole}").performClick() + onNodeWithTag("cancelButton").performClick() + onNodeWithTag("memberItem-${member.user.uid}").assertTextContains(RoleType.PRESIDENCY.name) + } + } + + @Test + fun deleteMember() { + with(composeTestRule) { + val member = assoMembers[1] + onNodeWithTag("memberItem-${assoMembers[0].user.uid}").assertIsDisplayed() + onNodeWithTag("deleteMemberButton-0").performClick() + onNodeWithText("You cannot remove yourself").assertIsDisplayed() + onNodeWithTag("deleteMemberButton-1").performClick() + onNodeWithText("Are you sure you want to remove ${member.user.name} from the association?") + .assertIsDisplayed() + onNodeWithTag("cancelButton").performClick() + onNodeWithTag("deleteMemberButton-1").performClick() + onNodeWithTag("confirmButton").performClick() + onNodeWithTag("memberItem-${member.user.uid}").assertDoesNotExist() + } + } + @Test fun goBack() { with(composeTestRule) { diff --git a/app/src/main/java/com/github/se/assocify/model/database/UserAPI.kt b/app/src/main/java/com/github/se/assocify/model/database/UserAPI.kt index a36b199a9..50ca5582c 100644 --- a/app/src/main/java/com/github/se/assocify/model/database/UserAPI.kt +++ b/app/src/main/java/com/github/se/assocify/model/database/UserAPI.kt @@ -388,9 +388,10 @@ class UserAPI(private val db: SupabaseClient, cachePath: Path) : SupabaseApi() { }) { filter { eq("user_id", userId) - eq("role_id", roleIDToChange.toString().drop(1).dropLast(1)) + eq("role_id", roleIDToChange!!["uid"].toString().drop(1).dropLast(1)) } } + onSuccess() } } 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 index 66785e152..f9a3fb828 100644 --- 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 @@ -1,52 +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)) - } -} +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/event/scheduletab/EventScheduleViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/event/scheduletab/EventScheduleViewModel.kt index 5175b058c..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 @@ -1,332 +1,332 @@ -package com.github.se.assocify.ui.screens.event.scheduletab - -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.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 -import kotlinx.coroutines.flow.StateFlow - -/** A ViewModel for the EventScheduleScreen. */ -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()) - 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 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) - loadSystem.end() - }, - { 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") }) - } - - /** - * Filters the tasks based on the current date and the events that are selected. - * - * @param tasks The tasks to filter. - */ - private fun filterTasks(tasks: List = _uiState.value.tasks) { - val filteredTasks = tasks.filter { it.eventUid in _uiState.value.filteredEventsUid } - val dayTasks = dayTasks(filteredTasks).sortedBy { it.startTime } - val clampedTasks = clampTasksDuration(dayTasks) - val overlappingTasks = overlappingTasks(clampedTasks) - _uiState.value = _uiState.value.copy(currentDayTasks = overlappingTasks) - } - - /** - * Changes the current date and updates the UI state. - * - * @param date The new date. - */ - fun changeDate(date: LocalDate?) { - if (date == null) return - - _uiState.value = _uiState.value.copy(currentDate = date) - val dateText = - when (date) { - LocalDate.now() -> "Today" - LocalDate.now().plusDays(1) -> "Tomorrow" - LocalDate.now().minusDays(1) -> "Yesterday" - else -> DateUtil.formatVerboseDate(date) - } - _uiState.value = _uiState.value.copy(dateText = dateText) - filterTasks() - } - - /** Changes the current date to the next day. */ - fun nextDate() { - changeDate(_uiState.value.currentDate.plusDays(1)) - } - - /** Changes the current date to the previous day. */ - fun previousDate() { - changeDate(_uiState.value.currentDate.minusDays(1)) - } - - fun openTask(uid: String) { - navActions.navigateTo(Destination.EditTask(uid)) - } - - /** - * Sets the events to filter the tasks by. - * - * @param events The events to filter by. - */ - fun setEvents(events: List) { - _uiState.value = _uiState.value.copy(filteredEventsUid = events.map { it.uid }) - filterTasks() - } - - /** Filters the tasks to only include tasks that start or end on the current day. */ - private fun dayTasks(tasks: List): List { - // Filter tasks that start on the current day - val startAtDay = - tasks.filter { - // Check if the task starts on the current day - DateTimeUtil.toLocalDate(it.startTime) == _uiState.value.currentDate - } - - // Filter tasks that end on the current day - val endAtDay = - tasks - .filter { - // Check if the task ends on the current day - DateTimeUtil.toLocalDate(it.startTime.plusMinutes(it.duration.toLong())) == - _uiState.value.currentDate - } - .filter { - // Check if the task is not already in startAtDay - it !in startAtDay - } - .map { - val endTime = it.startTime.plusMinutes(it.duration.toLong()) - val currentDayDuration = DateTimeUtil.toLocalTime(endTime).toSecondOfDay() / 60 - it.copy( - // Set the start time to the beginning of the day - startTime = - DateTimeUtil.toOffsetDateTime(_uiState.value.currentDate, LocalTime.MIN), - // Set the duration to the remaining time of the task - duration = currentDayDuration) - } - - // Filter tasks that are ongoing on the current day - val ongoingTasks = - tasks - .filter { - // Check if the task starts before the current day and ends after the current day - DateTimeUtil.toLocalDate(it.startTime) < _uiState.value.currentDate && - DateTimeUtil.toLocalDate(it.startTime.plusMinutes(it.duration.toLong())) > - _uiState.value.currentDate - } - .map { - val startTime = - DateTimeUtil.toOffsetDateTime(LocalTime.MIN.atDate(_uiState.value.currentDate)) - val duration = (LocalTime.MAX.toSecondOfDay() - LocalTime.MIN.toSecondOfDay()) / 60 - it.copy(startTime = startTime, duration = duration) - } - - val dayTasks = - (startAtDay + endAtDay + ongoingTasks).map { - // Clamp task time to end of day if it spans more than one day - val endTime = it.startTime.plusMinutes(it.duration.toLong()) - if (DateTimeUtil.toLocalDate(endTime) != _uiState.value.currentDate) { - val start = DateTimeUtil.toLocalTime(it.startTime).toSecondOfDay() / 60 - val end = LocalTime.MAX.toSecondOfDay() / 60 - val currentDayDuration = end - start - it.copy(duration = currentDayDuration) - } else { - it - } - } - return dayTasks - } - - /** - * Clamps the duration of tasks to a minimum of 30 minutes. - * - * @param tasks The tasks to clamp. - */ - private fun clampTasksDuration(tasks: List): List { - return tasks.map { - if (it.duration < 30) { - it.copy(duration = 30) - } else { - it - } - } - } - - /** - * Converts a list of tasks to a list of OverlapTasks. OverlapTasks are used to display tasks in - * the schedule and contain information about the task's position (order) and width (overlaps). - * - * @param tasks The tasks to convert. - */ - private fun overlappingTasks(tasks: List): List { - - val overlapTasks = mutableListOf() - - val connectedGroups = findConnectedGroups(tasks) - for (group in connectedGroups) { - - val collisionsPerTimeSlot = findCollisionsPerTimeSlot(group) - val maxWidth = collisionsPerTimeSlot.maxOf { it.size } - - val columns = mutableListOf>() - for (i in 0 until maxWidth) { - columns.add(emptyList()) - } - - for (task in group) { - for (i in 0 until maxWidth) { - val column = columns[i] - if (column.none { checkOverlap(task, it) }) { - columns[i] = column + task - overlapTasks.add(OverlapTask(task, maxWidth, i)) - break - } - } - } - } - - return overlapTasks - } - - /** - * Finds the connected groups of tasks. A connected group is a group of tasks that overlap with - * each other. - * - * @param tasks The tasks to find the connected groups for. - */ - private fun findConnectedGroups(tasks: List): List> { - val connectedGroups = mutableListOf>() - for (task in tasks) { - var connected = false - for (j in connectedGroups.indices) { - val group = connectedGroups[j] - if (group.any { checkOverlap(task, it) }) { - connected = true - connectedGroups[j] = group + task - break - } - } - if (!connected) { - connectedGroups.add(listOf(task)) - } - } - return connectedGroups - } - - /** - * Finds the number of tasks that overlap in each 30 minute time slot. This is used to determine - * the maximum number of tasks that overlap in a single time slot. - * - * @param tasks The tasks to find the collisions for. - */ - private fun findCollisionsPerTimeSlot(tasks: List): List> { - val firstSlot = DateTimeUtil.toLocalTime(tasks.first().startTime).toSecondOfDay() / 1800 - val collisionsPerTimeSlot = mutableListOf>() - for (i in 0 until 48) { - collisionsPerTimeSlot.add(emptyList()) - } - for (task in tasks) { - val startSlot = DateTimeUtil.toLocalTime(task.startTime).toSecondOfDay() / 1800 - firstSlot - val endSlot = - DateTimeUtil.toLocalTime(task.startTime.plusMinutes(task.duration.toLong())) - .toSecondOfDay() / 1800 - firstSlot - for (i in startSlot until endSlot) { - collisionsPerTimeSlot[i] = collisionsPerTimeSlot[i] + task - } - } - return collisionsPerTimeSlot - } - - /** - * Checks if two tasks overlap. - * - * @param task1 The first task. - * @param task2 The second task. - */ - private fun checkOverlap(task1: Task, task2: Task): Boolean { - val startOverlap = - task1.startTime >= task2.startTime && - task1.startTime < task2.startTime.plusMinutes(task2.duration.toLong()) - val endOverlap = - task2.startTime >= task1.startTime && - task2.startTime < task1.startTime.plusMinutes(task1.duration.toLong()) - return startOverlap || endOverlap - } -} - -/** - * The state of the EventScheduleViewModel. - * - * @param loading Whether the tasks are loading. - * @param error An error message if the tasks could not be loaded. - * @param tasks The tasks to display. - * @param currentDate The current date. - * @param currentDayTasks The tasks for the current day. - * @param dateText The text to display for the current date. - * @param filteredEventsUid The events to filter the tasks by. - */ -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(), - val dateText: String = "Today", - val filteredEventsUid: List = emptyList(), -) - -data class OverlapTask( - val task: Task, - val overlaps: Int, - val order: Int, -) +package com.github.se.assocify.ui.screens.event.scheduletab + +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.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 +import kotlinx.coroutines.flow.StateFlow + +/** A ViewModel for the EventScheduleScreen. */ +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()) + 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 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) + loadSystem.end() + }, + { 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") }) + } + + /** + * Filters the tasks based on the current date and the events that are selected. + * + * @param tasks The tasks to filter. + */ + private fun filterTasks(tasks: List = _uiState.value.tasks) { + val filteredTasks = tasks.filter { it.eventUid in _uiState.value.filteredEventsUid } + val dayTasks = dayTasks(filteredTasks).sortedBy { it.startTime } + val clampedTasks = clampTasksDuration(dayTasks) + val overlappingTasks = overlappingTasks(clampedTasks) + _uiState.value = _uiState.value.copy(currentDayTasks = overlappingTasks) + } + + /** + * Changes the current date and updates the UI state. + * + * @param date The new date. + */ + fun changeDate(date: LocalDate?) { + if (date == null) return + + _uiState.value = _uiState.value.copy(currentDate = date) + val dateText = + when (date) { + LocalDate.now() -> "Today" + LocalDate.now().plusDays(1) -> "Tomorrow" + LocalDate.now().minusDays(1) -> "Yesterday" + else -> DateUtil.formatVerboseDate(date) + } + _uiState.value = _uiState.value.copy(dateText = dateText) + filterTasks() + } + + /** Changes the current date to the next day. */ + fun nextDate() { + changeDate(_uiState.value.currentDate.plusDays(1)) + } + + /** Changes the current date to the previous day. */ + fun previousDate() { + changeDate(_uiState.value.currentDate.minusDays(1)) + } + + fun openTask(uid: String) { + navActions.navigateTo(Destination.EditTask(uid)) + } + + /** + * Sets the events to filter the tasks by. + * + * @param events The events to filter by. + */ + fun setEvents(events: List) { + _uiState.value = _uiState.value.copy(filteredEventsUid = events.map { it.uid }) + filterTasks() + } + + /** Filters the tasks to only include tasks that start or end on the current day. */ + private fun dayTasks(tasks: List): List { + // Filter tasks that start on the current day + val startAtDay = + tasks.filter { + // Check if the task starts on the current day + DateTimeUtil.toLocalDate(it.startTime) == _uiState.value.currentDate + } + + // Filter tasks that end on the current day + val endAtDay = + tasks + .filter { + // Check if the task ends on the current day + DateTimeUtil.toLocalDate(it.startTime.plusMinutes(it.duration.toLong())) == + _uiState.value.currentDate + } + .filter { + // Check if the task is not already in startAtDay + it !in startAtDay + } + .map { + val endTime = it.startTime.plusMinutes(it.duration.toLong()) + val currentDayDuration = DateTimeUtil.toLocalTime(endTime).toSecondOfDay() / 60 + it.copy( + // Set the start time to the beginning of the day + startTime = + DateTimeUtil.toOffsetDateTime(_uiState.value.currentDate, LocalTime.MIN), + // Set the duration to the remaining time of the task + duration = currentDayDuration) + } + + // Filter tasks that are ongoing on the current day + val ongoingTasks = + tasks + .filter { + // Check if the task starts before the current day and ends after the current day + DateTimeUtil.toLocalDate(it.startTime) < _uiState.value.currentDate && + DateTimeUtil.toLocalDate(it.startTime.plusMinutes(it.duration.toLong())) > + _uiState.value.currentDate + } + .map { + val startTime = + DateTimeUtil.toOffsetDateTime(LocalTime.MIN.atDate(_uiState.value.currentDate)) + val duration = (LocalTime.MAX.toSecondOfDay() - LocalTime.MIN.toSecondOfDay()) / 60 + it.copy(startTime = startTime, duration = duration) + } + + val dayTasks = + (startAtDay + endAtDay + ongoingTasks).map { + // Clamp task time to end of day if it spans more than one day + val endTime = it.startTime.plusMinutes(it.duration.toLong()) + if (DateTimeUtil.toLocalDate(endTime) != _uiState.value.currentDate) { + val start = DateTimeUtil.toLocalTime(it.startTime).toSecondOfDay() / 60 + val end = LocalTime.MAX.toSecondOfDay() / 60 + val currentDayDuration = end - start + it.copy(duration = currentDayDuration) + } else { + it + } + } + return dayTasks + } + + /** + * Clamps the duration of tasks to a minimum of 30 minutes. + * + * @param tasks The tasks to clamp. + */ + private fun clampTasksDuration(tasks: List): List { + return tasks.map { + if (it.duration < 30) { + it.copy(duration = 30) + } else { + it + } + } + } + + /** + * Converts a list of tasks to a list of OverlapTasks. OverlapTasks are used to display tasks in + * the schedule and contain information about the task's position (order) and width (overlaps). + * + * @param tasks The tasks to convert. + */ + private fun overlappingTasks(tasks: List): List { + + val overlapTasks = mutableListOf() + + val connectedGroups = findConnectedGroups(tasks) + for (group in connectedGroups) { + + val collisionsPerTimeSlot = findCollisionsPerTimeSlot(group) + val maxWidth = collisionsPerTimeSlot.maxOf { it.size } + + val columns = mutableListOf>() + for (i in 0 until maxWidth) { + columns.add(emptyList()) + } + + for (task in group) { + for (i in 0 until maxWidth) { + val column = columns[i] + if (column.none { checkOverlap(task, it) }) { + columns[i] = column + task + overlapTasks.add(OverlapTask(task, maxWidth, i)) + break + } + } + } + } + + return overlapTasks + } + + /** + * Finds the connected groups of tasks. A connected group is a group of tasks that overlap with + * each other. + * + * @param tasks The tasks to find the connected groups for. + */ + private fun findConnectedGroups(tasks: List): List> { + val connectedGroups = mutableListOf>() + for (task in tasks) { + var connected = false + for (j in connectedGroups.indices) { + val group = connectedGroups[j] + if (group.any { checkOverlap(task, it) }) { + connected = true + connectedGroups[j] = group + task + break + } + } + if (!connected) { + connectedGroups.add(listOf(task)) + } + } + return connectedGroups + } + + /** + * Finds the number of tasks that overlap in each 30 minute time slot. This is used to determine + * the maximum number of tasks that overlap in a single time slot. + * + * @param tasks The tasks to find the collisions for. + */ + private fun findCollisionsPerTimeSlot(tasks: List): List> { + val firstSlot = DateTimeUtil.toLocalTime(tasks.first().startTime).toSecondOfDay() / 1800 + val collisionsPerTimeSlot = mutableListOf>() + for (i in 0 until 48) { + collisionsPerTimeSlot.add(emptyList()) + } + for (task in tasks) { + val startSlot = DateTimeUtil.toLocalTime(task.startTime).toSecondOfDay() / 1800 - firstSlot + val endSlot = + DateTimeUtil.toLocalTime(task.startTime.plusMinutes(task.duration.toLong())) + .toSecondOfDay() / 1800 - firstSlot + for (i in startSlot until endSlot) { + collisionsPerTimeSlot[i] = collisionsPerTimeSlot[i] + task + } + } + return collisionsPerTimeSlot + } + + /** + * Checks if two tasks overlap. + * + * @param task1 The first task. + * @param task2 The second task. + */ + private fun checkOverlap(task1: Task, task2: Task): Boolean { + val startOverlap = + task1.startTime >= task2.startTime && + task1.startTime < task2.startTime.plusMinutes(task2.duration.toLong()) + val endOverlap = + task2.startTime >= task1.startTime && + task2.startTime < task1.startTime.plusMinutes(task1.duration.toLong()) + return startOverlap || endOverlap + } +} + +/** + * The state of the EventScheduleViewModel. + * + * @param loading Whether the tasks are loading. + * @param error An error message if the tasks could not be loaded. + * @param tasks The tasks to display. + * @param currentDate The current date. + * @param currentDayTasks The tasks for the current day. + * @param dateText The text to display for the current date. + * @param filteredEventsUid The events to filter the tasks by. + */ +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(), + val dateText: String = "Today", + val filteredEventsUid: List = emptyList(), +) + +data class OverlapTask( + val task: Task, + val overlaps: Int, + val order: Int, +) diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileGraph.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileGraph.kt index 7e9f01f4d..e9edc04af 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileGraph.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/ProfileGraph.kt @@ -33,7 +33,7 @@ fun NavGraphBuilder.profileGraph( } profilePreferencesGraph(navigationActions, appThemeVM, localSave) - profileMembersGraph(navigationActions, associationAPI) + profileMembersGraph(navigationActions, associationAPI, userAPI) profileTreasuryTagsGraph(navigationActions, accountingCategoryAPI) profileEventsGraph(navigationActions, eventAPI) } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersGraph.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersGraph.kt index 00abe1488..2ff9173a8 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersGraph.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersGraph.kt @@ -1,17 +1,20 @@ package com.github.se.assocify.ui.screens.profile.members +import androidx.compose.runtime.remember import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.github.se.assocify.model.database.AssociationAPI +import com.github.se.assocify.model.database.UserAPI import com.github.se.assocify.navigation.Destination import com.github.se.assocify.navigation.NavigationActions fun NavGraphBuilder.profileMembersGraph( navigationActions: NavigationActions, - associationAPI: AssociationAPI + associationAPI: AssociationAPI, + userAPI: UserAPI ) { composable(route = Destination.ProfileMembers.route) { - ProfileMembersScreen( - navigationActions, ProfileMembersViewModel(navigationActions, associationAPI)) + val viewModel = remember { ProfileMembersViewModel(associationAPI, userAPI) } + ProfileMembersScreen(navigationActions, viewModel) } } diff --git a/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersScreen.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersScreen.kt index eb1e3659b..20a460ee0 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersScreen.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersScreen.kt @@ -1,19 +1,20 @@ package com.github.se.assocify.ui.screens.profile.members +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.filled.Check -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.CardDefaults +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api @@ -22,19 +23,24 @@ 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.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost 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.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.github.se.assocify.model.entities.RoleType import com.github.se.assocify.navigation.NavigationActions import com.github.se.assocify.ui.composables.BackButton +import com.github.se.assocify.ui.composables.PullDownRefreshBox @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -43,6 +49,7 @@ fun ProfileMembersScreen( profileMembersViewModel: ProfileMembersViewModel ) { val state by profileMembersViewModel.uiState.collectAsState() + Scaffold( modifier = Modifier.testTag("Members Screen"), topBar = { @@ -55,64 +62,118 @@ fun ProfileMembersScreen( modifier = Modifier.testTag("backButton")) }) }, + snackbarHost = { SnackbarHost(hostState = state.snackbarHostState) }, contentWindowInsets = WindowInsets(20.dp, 20.dp, 20.dp, 0.dp), ) { - LazyColumn(modifier = Modifier.fillMaxSize().padding(it).testTag("membersScreen")) { - item { Text(text = "New requests", style = MaterialTheme.typography.titleMedium) } - - item { Spacer(Modifier.height(16.dp)) } + PullDownRefreshBox( + refreshing = state.refresh, onRefresh = { profileMembersViewModel.refreshMembers() }) { + LazyColumn(modifier = Modifier.fillMaxSize().padding(it).testTag("membersScreen")) { + item { Text(text = "Current members", style = MaterialTheme.typography.titleMedium) } - state.applicants.forEach { applicant -> - item { - ElevatedCard( - elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), - modifier = Modifier.fillMaxWidth().testTag("applicantCard-${applicant.uid}"), - ) { - Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { - Text( - text = applicant.name, - modifier = Modifier.padding(16.dp).weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + item { Spacer(Modifier.height(16.dp)) } - Row(horizontalArrangement = Arrangement.End) { - IconButton(onClick = {}, modifier = Modifier.testTag("rejectButton")) { - Icon( - Icons.Default.Close, - contentDescription = "Reject", - tint = MaterialTheme.colorScheme.error) - } - IconButton(onClick = {}, modifier = Modifier.testTag("acceptButton")) { - Icon(Icons.Default.Check, contentDescription = "Accept", tint = Color.Green) - } + state.currMembers.forEachIndexed { i, member -> + item { + if (i == 0) HorizontalDivider() + ListItem( + modifier = Modifier.testTag("memberItem-${member.user.uid}"), + headlineContent = { Text(text = member.user.name) }, + trailingContent = { + Row { + IconButton( + onClick = { profileMembersViewModel.onEditMember(member) }, + modifier = Modifier.testTag("editButton-$i")) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + IconButton( + onClick = { profileMembersViewModel.onDeleteMember(member) }, + modifier = Modifier.testTag("deleteMemberButton-$i")) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + } + }, + supportingContent = { Text(text = member.role.type.name) }) + HorizontalDivider() } } } - } - } - item { Spacer(Modifier.height(16.dp)) } + // Edit member's role dialog + if (state.showEditMemberDialog) { + Dialog(onDismissRequest = { profileMembersViewModel.onEditMemberDialogDismiss() }) { + ElevatedCard { + Column( + modifier = Modifier.padding(16.dp).testTag("editMemberDialog"), + horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Change ${state.updatingMember?.user?.name}'s role ?", + style = MaterialTheme.typography.titleMedium) - item { Text(text = "Current members", style = MaterialTheme.typography.titleMedium) } + Spacer(modifier = Modifier.height(16.dp)) - item { Spacer(Modifier.height(16.dp)) } + RoleType.entries.forEach { role -> + ListItem( + headlineContent = { Text(role.name.uppercase()) }, + trailingContent = { + RadioButton( + modifier = Modifier.testTag("role-${role.name}"), + selected = state.newRole == role, + onClick = { profileMembersViewModel.updateRole(role) }) + }, + modifier = Modifier.testTag("roleitem-${role.name}")) + } - state.currMembers.forEachIndexed { i, member -> - item { - if (i == 0) HorizontalDivider() - ListItem( - modifier = Modifier.testTag("memberItem-${member.user.uid}"), - headlineContent = { Text(text = member.user.name) }, - trailingContent = { - IconButton(onClick = {}, modifier = Modifier.testTag("editButton")) { - Icon(Icons.Default.Edit, contentDescription = "Edit") - } - }, - supportingContent = { Text(text = member.role.type.name) }) - HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedButton( + onClick = { profileMembersViewModel.onEditMemberDialogDismiss() }, + modifier = Modifier.wrapContentSize().testTag("cancelButton"), + ) { + Text(text = "Cancel", textAlign = TextAlign.Center) + } + OutlinedButton( + onClick = { profileMembersViewModel.confirmEditMember() }, + modifier = Modifier.wrapContentSize().testTag("confirmButton")) { + Text(text = "Confirm", textAlign = TextAlign.Center) + } + } + } + } + } + } + + // Confirm delete dialog + if (state.showDeleteMemberDialog) { + Dialog(onDismissRequest = { profileMembersViewModel.onDeleteMemberDialogDismiss() }) { + ElevatedCard { + Column( + modifier = Modifier.padding(16.dp).testTag("deleteMemberDialog"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + "Are you sure you want to remove ${state.updatingMember?.user?.name} from the association?") + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedButton( + onClick = { profileMembersViewModel.onDeleteMemberDialogDismiss() }, + modifier = Modifier.wrapContentSize().testTag("cancelButton"), + ) { + Text(text = "Cancel", textAlign = TextAlign.Center) + } + OutlinedButton( + onClick = { profileMembersViewModel.confirmDeleteMember() }, + 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/members/ProfileMembersViewModel.kt b/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersViewModel.kt index b28d15665..e3acafd0c 100644 --- a/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersViewModel.kt +++ b/app/src/main/java/com/github/se/assocify/ui/screens/profile/members/ProfileMembersViewModel.kt @@ -1,33 +1,108 @@ package com.github.se.assocify.ui.screens.profile.members -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.AssociationAPI +import com.github.se.assocify.model.database.UserAPI import com.github.se.assocify.model.entities.AssociationMember -import com.github.se.assocify.model.entities.User -import com.github.se.assocify.navigation.NavigationActions +import com.github.se.assocify.model.entities.RoleType +import com.github.se.assocify.ui.util.SnackbarSystem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class ProfileMembersViewModel(navActions: NavigationActions, associationAPI: AssociationAPI) : - ViewModel() { +class ProfileMembersViewModel( + private val associationAPI: AssociationAPI, + private val userAPI: UserAPI +) : ViewModel() { private val _uiState = MutableStateFlow(ProfileMembersUIState()) val uiState: StateFlow = _uiState + val snackbarSystem = SnackbarSystem(_uiState.value.snackbarHostState) + init { - associationAPI.getApplicants( - CurrentUser.associationUid!!, - { applicants -> _uiState.value = _uiState.value.copy(applicants = applicants) }, - { Log.e("members", "Error loading applicants : ${it.message}") }) + loadMembers() + } + + private fun loadMembers() { associationAPI.getMembers( CurrentUser.associationUid!!, - { members -> _uiState.value = _uiState.value.copy(currMembers = members) }, - { Log.e("members", "Error loading members : ${it.message}") }) + { members -> _uiState.value = _uiState.value.copy(currMembers = members, refresh = false) }, + { + _uiState.value = _uiState.value.copy(refresh = false) + snackbarSystem.showSnackbar("Error loading members") + }) + } + + fun refreshMembers() { + _uiState.value = _uiState.value.copy(refresh = true) + associationAPI.updateCache( + { loadMembers() }, + { + _uiState.value = _uiState.value.copy(refresh = false) + snackbarSystem.showSnackbar("Error refreshing members") + }) + } + + fun onEditMember(member: AssociationMember) { + _uiState.value = + _uiState.value.copy( + updatingMember = member, showEditMemberDialog = true, newRole = member.role.type) + } + + fun onDeleteMember(member: AssociationMember) { + if (member.user.uid == CurrentUser.userUid) { + snackbarSystem.showSnackbar("You cannot remove yourself") + return + } + _uiState.value = _uiState.value.copy(updatingMember = member, showDeleteMemberDialog = true) + } + + fun onEditMemberDialogDismiss() { + _uiState.value = _uiState.value.copy(showEditMemberDialog = false, updatingMember = null) + } + + fun onDeleteMemberDialogDismiss() { + _uiState.value = _uiState.value.copy(showDeleteMemberDialog = false, updatingMember = null) + } + + fun confirmDeleteMember() { + userAPI.removeUserFromAssociation( + _uiState.value.updatingMember!!.user.uid, + CurrentUser.associationUid!!, + { + associationAPI.updateCache( + { loadMembers() }, { snackbarSystem.showSnackbar("Error loading members") }) + _uiState.value = + _uiState.value.copy(showDeleteMemberDialog = false, updatingMember = null) + }, + { snackbarSystem.showSnackbar("Could not remove member") }) + } + + fun updateRole(newRole: RoleType) { + _uiState.value = _uiState.value.copy(newRole = newRole) + } + + fun confirmEditMember() { + userAPI.changeRoleOfUser( + _uiState.value.updatingMember!!.user.uid, + CurrentUser.associationUid!!, + _uiState.value.newRole!!, + { + associationAPI.updateCache( + { loadMembers() }, { snackbarSystem.showSnackbar("Error loading members") }) + _uiState.value = _uiState.value.copy(showEditMemberDialog = false, updatingMember = null) + }, + { snackbarSystem.showSnackbar("Could not change role") }) } } data class ProfileMembersUIState( + val refresh: Boolean = false, + val snackbarHostState: SnackbarHostState = SnackbarHostState(), val currMembers: List = emptyList(), - val applicants: List = emptyList() + val updatingMember: AssociationMember? = null, + val newRole: RoleType? = null, + val showEditMemberDialog: Boolean = false, + val showDeleteMemberDialog: Boolean = false, ) diff --git a/app/src/main/java/com/github/se/assocify/ui/util/DateTimeUtil.kt b/app/src/main/java/com/github/se/assocify/ui/util/DateTimeUtil.kt index c3262beb8..4d83afcff 100644 --- a/app/src/main/java/com/github/se/assocify/ui/util/DateTimeUtil.kt +++ b/app/src/main/java/com/github/se/assocify/ui/util/DateTimeUtil.kt @@ -1,42 +1,42 @@ -package com.github.se.assocify.ui.util - -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.OffsetDateTime -import java.time.ZoneId - -object DateTimeUtil { - /** Formats an OffsetDateTime to a string. */ - fun formatDateTime(offsetDateTime: OffsetDateTime): String { - val localDateTime = toLocalDateTime(offsetDateTime) - return DateUtil.formatDate(localDateTime.toLocalDate()) + - " " + - TimeUtil.formatTime(localDateTime.toLocalTime()) - } - - /** Converts an OffsetDateTime to a LocalDateTime. */ - fun toLocalDateTime(offsetDateTime: OffsetDateTime): LocalDateTime { - return offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() - } - - /** Converts an OffsetDateTime to a LocalDate. */ - fun toLocalDate(offsetDateTime: OffsetDateTime): LocalDate { - return offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()).toLocalDate() - } - - /** Converts an OffsetDateTime to a LocalTime. */ - fun toLocalTime(offsetDateTime: OffsetDateTime): LocalTime { - return offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()).toLocalTime() - } - - /** Converts a LocalDateTime to an OffsetDateTime. */ - fun toOffsetDateTime(localDateTime: LocalDateTime): OffsetDateTime { - return localDateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime() - } - - /** Converts a LocalDate and a LocalTime to an OffsetDateTime. */ - fun toOffsetDateTime(localDate: LocalDate, localTime: LocalTime): OffsetDateTime { - return localDate.atTime(localTime).atZone(ZoneId.systemDefault()).toOffsetDateTime() - } -} +package com.github.se.assocify.ui.util + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.ZoneId + +object DateTimeUtil { + /** Formats an OffsetDateTime to a string. */ + fun formatDateTime(offsetDateTime: OffsetDateTime): String { + val localDateTime = toLocalDateTime(offsetDateTime) + return DateUtil.formatDate(localDateTime.toLocalDate()) + + " " + + TimeUtil.formatTime(localDateTime.toLocalTime()) + } + + /** Converts an OffsetDateTime to a LocalDateTime. */ + fun toLocalDateTime(offsetDateTime: OffsetDateTime): LocalDateTime { + return offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() + } + + /** Converts an OffsetDateTime to a LocalDate. */ + fun toLocalDate(offsetDateTime: OffsetDateTime): LocalDate { + return offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()).toLocalDate() + } + + /** Converts an OffsetDateTime to a LocalTime. */ + fun toLocalTime(offsetDateTime: OffsetDateTime): LocalTime { + return offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()).toLocalTime() + } + + /** Converts a LocalDateTime to an OffsetDateTime. */ + fun toOffsetDateTime(localDateTime: LocalDateTime): OffsetDateTime { + return localDateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime() + } + + /** Converts a LocalDate and a LocalTime to an OffsetDateTime. */ + fun toOffsetDateTime(localDate: LocalDate, localTime: LocalTime): OffsetDateTime { + return localDate.atTime(localTime).atZone(ZoneId.systemDefault()).toOffsetDateTime() + } +} diff --git a/app/src/main/java/com/github/se/assocify/ui/util/DurationUtil.kt b/app/src/main/java/com/github/se/assocify/ui/util/DurationUtil.kt index 9743df3ef..47f28c474 100644 --- a/app/src/main/java/com/github/se/assocify/ui/util/DurationUtil.kt +++ b/app/src/main/java/com/github/se/assocify/ui/util/DurationUtil.kt @@ -1,51 +1,51 @@ -package com.github.se.assocify.ui.util - -import java.time.LocalTime -import java.time.format.DateTimeFormatter - -object DurationUtil { - - const val NULL_DURATION_STRING = "0h" - - /** - * Converts duration to a string. If the time is null, it returns a string representing a null - * time. - */ - fun formatTime(time: LocalTime?): String { - if (time == null) { - return NULL_DURATION_STRING - } - return time.format(DateTimeFormatter.ofPattern("HH:mm")) - } - - /** - * Converts duration to a string. If the time is null, it returns a string representing a null - * time. - */ - fun formatTime(time: Int): String { - val localTime = LocalTime.of(time / 60, time % 60) - return localTime.format(DateTimeFormatter.ofPattern("HH:mm")) - } - - /** - * Converts a string to a time. If the string is empty or represents a null time, it returns null. - */ - fun toTime(time: String): LocalTime? { - if (time.isEmpty() || time == NULL_DURATION_STRING) { - return null - } - return LocalTime.parse(time, DateTimeFormatter.ofPattern("HH:mm")) - } - - /** - * Converts a string to a duration in minutes. If the string is empty or represents a null time, - * it returns 0. - */ - fun toDuration(time: String): Int { - if (time.isEmpty() || time == NULL_DURATION_STRING) { - return 0 - } - val localTime = LocalTime.parse(time, DateTimeFormatter.ofPattern("HH:mm")) - return localTime.hour * 60 + localTime.minute - } -} +package com.github.se.assocify.ui.util + +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +object DurationUtil { + + const val NULL_DURATION_STRING = "0h" + + /** + * Converts duration to a string. If the time is null, it returns a string representing a null + * time. + */ + fun formatTime(time: LocalTime?): String { + if (time == null) { + return NULL_DURATION_STRING + } + return time.format(DateTimeFormatter.ofPattern("HH:mm")) + } + + /** + * Converts duration to a string. If the time is null, it returns a string representing a null + * time. + */ + fun formatTime(time: Int): String { + val localTime = LocalTime.of(time / 60, time % 60) + return localTime.format(DateTimeFormatter.ofPattern("HH:mm")) + } + + /** + * Converts a string to a time. If the string is empty or represents a null time, it returns null. + */ + fun toTime(time: String): LocalTime? { + if (time.isEmpty() || time == NULL_DURATION_STRING) { + return null + } + return LocalTime.parse(time, DateTimeFormatter.ofPattern("HH:mm")) + } + + /** + * Converts a string to a duration in minutes. If the string is empty or represents a null time, + * it returns 0. + */ + fun toDuration(time: String): Int { + if (time.isEmpty() || time == NULL_DURATION_STRING) { + return 0 + } + val localTime = LocalTime.parse(time, DateTimeFormatter.ofPattern("HH:mm")) + return localTime.hour * 60 + localTime.minute + } +} 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 index ef23ba431..50f7c8324 100644 --- 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 @@ -1,53 +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() - } - } - } -} +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() + } + } + } +}