diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/ConferenceListRenderTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/ConferenceListRenderTest.kt index 4f46583e3e..199746f2bb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/ConferenceListRenderTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/ConferenceListRenderTest.kt @@ -20,6 +20,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Group +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListItemViewState import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListRepositoryFragment import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListViewState @@ -81,7 +82,11 @@ class ConferenceListRenderTest : StudentRenderTest() { fun displaysListItems() { val tint = Color.BLUE val itemStates = listOf( - ConferenceListItemViewState.ConferenceHeader("Header 1"), + ConferenceListItemViewState.ConferenceHeader( + title = "Header 1", + headerType = ConferenceHeaderType.NEW_CONFERENCES, + isExpanded = true + ), ConferenceListItemViewState.ConferenceItem( tint = tint, title = "Conference 1", @@ -100,7 +105,11 @@ class ConferenceListRenderTest : StudentRenderTest() { conferenceId = 0, isJoinable = false ), - ConferenceListItemViewState.ConferenceHeader("Header 2"), + ConferenceListItemViewState.ConferenceHeader( + title = "Header 2", + headerType = ConferenceHeaderType.CONCLUDED_CONFERENCES, + isExpanded = true + ), ConferenceListItemViewState.ConferenceItem( tint = tint, title = "Conference 3", diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListModels.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListModels.kt index 465b9c8ca2..f229897137 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListModels.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListModels.kt @@ -26,6 +26,12 @@ sealed class ConferenceListEvent { object LaunchInBrowserFinished : ConferenceListEvent() data class DataLoaded(val listResult: DataResult>) : ConferenceListEvent() data class ConferenceClicked(val conferenceId: Long) : ConferenceListEvent() + data class HeaderClicked(val headerType: ConferenceHeaderType) : ConferenceListEvent() +} + +enum class ConferenceHeaderType { + NEW_CONFERENCES, + CONCLUDED_CONFERENCES } sealed class ConferenceListEffect { @@ -38,5 +44,7 @@ data class ConferenceListModel( val canvasContext: CanvasContext, val isLoading: Boolean = false, val isLaunchingInBrowser: Boolean = false, - val listResult: DataResult>? = null + val listResult: DataResult>? = null, + val isNewConferencesExpanded: Boolean = true, + val isConcludedConferencesExpanded: Boolean = true ) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListPresenter.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListPresenter.kt index 6dff778f60..412caba6d7 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListPresenter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListPresenter.kt @@ -58,26 +58,38 @@ object ConferenceListPresenter : Presenter Next.next(model.copy(isLaunchingInBrowser = false)) + is ConferenceListEvent.HeaderClicked -> { + when (event.headerType) { + ConferenceHeaderType.NEW_CONFERENCES -> { + Next.next(model.copy(isNewConferencesExpanded = !model.isNewConferencesExpanded)) + } + ConferenceHeaderType.CONCLUDED_CONFERENCES -> { + Next.next(model.copy(isConcludedConferencesExpanded = !model.isConcludedConferencesExpanded)) + } + } + } } } } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListAdapter.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListAdapter.kt index a1d8705822..047a63f1cd 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListAdapter.kt @@ -26,10 +26,12 @@ import com.instructure.student.R import com.instructure.student.databinding.AdapterConferenceHeaderBinding import com.instructure.student.databinding.AdapterConferenceItemBinding import com.instructure.student.databinding.AdapterConferenceListErrorBinding +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType interface ConferenceListAdapterCallback : BasicItemCallback { fun onConferenceClicked(conferenceId: Long) fun reload() + fun onHeaderClicked(headerType: ConferenceHeaderType) } class ConferenceListAdapter(callback: ConferenceListAdapterCallback) : @@ -59,9 +61,18 @@ class ConferenceListErrorBinder : BasicItemBinder() { override val layoutResId = R.layout.adapter_conference_header - override val bindBehavior = Item {data, _, _ -> + override val bindBehavior = Item {data, callback, _ -> val binding = AdapterConferenceHeaderBinding.bind(this) binding.title.text = data.title + + binding.expandIcon.animate() + .rotation(if (data.isExpanded) 180f else 0f) + .setDuration(200) + .start() + + binding.headerContainer.onClick { + callback.onHeaderClicked(data.headerType) + } } } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListView.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListView.kt index 5e76e7b87a..7bb9d6daf8 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListView.kt @@ -39,6 +39,7 @@ import com.instructure.student.R import com.instructure.student.databinding.FragmentConferenceListBinding import com.instructure.student.mobius.common.ui.MobiusView import com.instructure.student.mobius.conferences.conference_details.ui.ConferenceDetailsRepositoryFragment +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType import com.instructure.student.mobius.conferences.conference_list.ConferenceListEvent import com.instructure.student.router.RouteMatcher import com.spotify.mobius.functions.Consumer @@ -84,6 +85,10 @@ class ConferenceListView( } override fun reload() = output.accept(ConferenceListEvent.PullToRefresh) + + override fun onHeaderClicked(headerType: ConferenceHeaderType) { + output.accept(ConferenceListEvent.HeaderClicked(headerType)) + } }) binding.recyclerView.layoutManager = LinearLayoutManager(context) binding.recyclerView.adapter = listAdapter diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListViewState.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListViewState.kt index d112af04a3..445484eb4a 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListViewState.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListViewState.kt @@ -16,6 +16,8 @@ */ package com.instructure.student.mobius.conferences.conference_list.ui +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType + sealed class ConferenceListViewState(open val isLaunchingInBrowser: Boolean) { class Loading(isLaunchingInBrowser: Boolean) : ConferenceListViewState(isLaunchingInBrowser) data class Loaded( @@ -27,7 +29,11 @@ sealed class ConferenceListViewState(open val isLaunchingInBrowser: Boolean) { sealed class ConferenceListItemViewState { object Empty : ConferenceListItemViewState() object Error : ConferenceListItemViewState() - data class ConferenceHeader(val title: String): ConferenceListItemViewState() + data class ConferenceHeader( + val title: String, + val headerType: ConferenceHeaderType, + val isExpanded: Boolean + ): ConferenceListItemViewState() data class ConferenceItem( val tint: Int, val title: String, diff --git a/apps/student/src/main/res/layout/adapter_conference_header.xml b/apps/student/src/main/res/layout/adapter_conference_header.xml index 1b3e831814..79ae40eb94 100644 --- a/apps/student/src/main/res/layout/adapter_conference_header.xml +++ b/apps/student/src/main/res/layout/adapter_conference_header.xml @@ -13,24 +13,38 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + android:paddingTop="16dp" + android:paddingBottom="8dp" + android:gravity="center_vertical" + android:background="?attr/selectableItemBackground"> - + + + diff --git a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListPresenterTest.kt b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListPresenterTest.kt index aea2c330a2..f879f53029 100644 --- a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListPresenterTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListPresenterTest.kt @@ -25,6 +25,7 @@ import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.utils.color import com.instructure.student.R +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType import com.instructure.student.mobius.conferences.conference_list.ConferenceListModel import com.instructure.student.mobius.conferences.conference_list.ConferenceListPresenter import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListItemViewState @@ -97,7 +98,11 @@ class ConferenceListPresenterTest : Assert() { // Generate state val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded - val expectedHeader = ConferenceListItemViewState.ConferenceHeader(context.getString(R.string.newConferences)) + val expectedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.newConferences), + ConferenceHeaderType.NEW_CONFERENCES, + true + ) val expectedConferenceItem = ConferenceListPresenter.mapItemState(canvasContext.color, conference, context) // Should have two list items - one header and one conference @@ -116,7 +121,11 @@ class ConferenceListPresenterTest : Assert() { // Generate state val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded - val expectedHeader = ConferenceListItemViewState.ConferenceHeader(context.getString(R.string.concludedConferences)) + val expectedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.concludedConferences), + ConferenceHeaderType.CONCLUDED_CONFERENCES, + true + ) val expectedConferenceItem = ConferenceListPresenter.mapItemState(canvasContext.color, conference, context) // Should have two list items - one header and one conference @@ -137,8 +146,16 @@ class ConferenceListPresenterTest : Assert() { // Generate state val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded - val newHeader = ConferenceListItemViewState.ConferenceHeader(context.getString(R.string.newConferences)) - val concludedHeader = ConferenceListItemViewState.ConferenceHeader(context.getString(R.string.concludedConferences)) + val newHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.newConferences), + ConferenceHeaderType.NEW_CONFERENCES, + true + ) + val concludedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.concludedConferences), + ConferenceHeaderType.CONCLUDED_CONFERENCES, + true + ) val inProgressItem = ConferenceListPresenter.mapItemState(canvasContext.color, inProgress, context) val notStartedItem = ConferenceListPresenter.mapItemState(canvasContext.color, notStarted, context) val concludedItem = ConferenceListPresenter.mapItemState(canvasContext.color, concluded, context) @@ -236,4 +253,81 @@ class ConferenceListPresenterTest : Assert() { assertEquals(state, expected) } + + @Test + fun `Returns only header when new conferences section is collapsed`() { + // Set up model with a not-started conference but collapsed state + val conference = Conference() + val result = DataResult.Success(listOf(conference)) + val model = ConferenceListModel(canvasContext, listResult = result, isNewConferencesExpanded = false) + + // Generate state + val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded + + val expectedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.newConferences), + ConferenceHeaderType.NEW_CONFERENCES, + false + ) + + // Should have only the header, no conference items + assertEquals(state.itemStates.size, 1) + assertEquals(state.itemStates[0], expectedHeader) + } + + @Test + fun `Returns only header when concluded conferences section is collapsed`() { + // Set up model with a concluded conference but collapsed state + val conference = Conference(startedAt = Date(), endedAt = Date()) + val result = DataResult.Success(listOf(conference)) + val model = ConferenceListModel(canvasContext, listResult = result, isConcludedConferencesExpanded = false) + + // Generate state + val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded + + val expectedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.concludedConferences), + ConferenceHeaderType.CONCLUDED_CONFERENCES, + false + ) + + // Should have only the header, no conference items + assertEquals(state.itemStates.size, 1) + assertEquals(state.itemStates[0], expectedHeader) + } + + @Test + fun `Sections can be collapsed independently`() { + // Set up model with both types but new conferences collapsed + val notStarted = Conference(startedAt = null, endedAt = null) + val concluded = Conference(startedAt = Date(), endedAt = Date()) + val result = DataResult.Success(listOf(notStarted, concluded)) + val model = ConferenceListModel( + canvasContext, + listResult = result, + isNewConferencesExpanded = false, + isConcludedConferencesExpanded = true + ) + + // Generate state + val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded + + val newHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.newConferences), + ConferenceHeaderType.NEW_CONFERENCES, + false + ) + val concludedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.concludedConferences), + ConferenceHeaderType.CONCLUDED_CONFERENCES, + true + ) + val concludedItem = ConferenceListPresenter.mapItemState(canvasContext.color, concluded, context) + + // Should have collapsed new header (no items), expanded concluded header with item + assertEquals(state.itemStates.size, 3) + assertEquals(state.itemStates[0], newHeader) + assertEquals(state.itemStates[1], concludedHeader) + assertEquals(state.itemStates[2], concludedItem) + } } diff --git a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListUpdateTest.kt b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListUpdateTest.kt index 2999d775ac..dbde35fbea 100644 --- a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListUpdateTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListUpdateTest.kt @@ -22,6 +22,7 @@ import com.instructure.canvasapi2.models.Conference import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType import com.instructure.student.mobius.conferences.conference_list.ConferenceListEffect import com.instructure.student.mobius.conferences.conference_list.ConferenceListEvent import com.instructure.student.mobius.conferences.conference_list.ConferenceListModel @@ -152,4 +153,85 @@ class ConferenceListUpdateTest : Assert() { ) ) } + + @Test + fun `HeaderClicked event with NEW_CONFERENCES toggles isNewConferencesExpanded from true to false`() { + val inputModel = initModel.copy(isNewConferencesExpanded = true) + val expectedModel = inputModel.copy(isNewConferencesExpanded = false) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.NEW_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } + + @Test + fun `HeaderClicked event with NEW_CONFERENCES toggles isNewConferencesExpanded from false to true`() { + val inputModel = initModel.copy(isNewConferencesExpanded = false) + val expectedModel = inputModel.copy(isNewConferencesExpanded = true) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.NEW_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } + + @Test + fun `HeaderClicked event with CONCLUDED_CONFERENCES toggles isConcludedConferencesExpanded from true to false`() { + val inputModel = initModel.copy(isConcludedConferencesExpanded = true) + val expectedModel = inputModel.copy(isConcludedConferencesExpanded = false) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.CONCLUDED_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } + + @Test + fun `HeaderClicked event with CONCLUDED_CONFERENCES toggles isConcludedConferencesExpanded from false to true`() { + val inputModel = initModel.copy(isConcludedConferencesExpanded = false) + val expectedModel = inputModel.copy(isConcludedConferencesExpanded = true) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.CONCLUDED_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } + + @Test + fun `HeaderClicked event only toggles the targeted section`() { + val inputModel = initModel.copy( + isNewConferencesExpanded = true, + isConcludedConferencesExpanded = false + ) + val expectedModel = inputModel.copy( + isNewConferencesExpanded = false, + isConcludedConferencesExpanded = false + ) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.NEW_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } } diff --git a/libs/pandautils/src/main/res/values/strings.xml b/libs/pandautils/src/main/res/values/strings.xml index 08cd2fdc95..3ac38d2694 100644 --- a/libs/pandautils/src/main/res/values/strings.xml +++ b/libs/pandautils/src/main/res/values/strings.xml @@ -487,6 +487,7 @@ %s Mark as done %s Mark as not done %s + Expand or collapse section Failed to load inbox signature Inbox settings saved! Failed to save inbox settings