diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt index 71db65c6da..8ef3d6697d 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt @@ -25,6 +25,7 @@ import android.view.ViewGroup import android.view.ViewTreeObserver import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.airbnb.mvrx.UniqueOnly @@ -60,6 +61,8 @@ import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.home.room.list.RoomListParams import im.vector.app.features.home.room.list.RoomListSectionBuilder.Companion.SPACE_ID_FOLLOW_APP import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import im.vector.app.features.home.room.list.home.spacebar.SpaceBarController +import im.vector.app.features.home.room.list.home.spacebar.SpaceBarData import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.VerificationVectorAlert import im.vector.app.features.settings.VectorLocaleProvider @@ -69,6 +72,7 @@ import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.workers.signout.BannerState import im.vector.app.features.workers.signout.ServerBackupStatusAction import im.vector.app.features.workers.signout.ServerBackupStatusViewModel +import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import org.matrix.android.sdk.api.session.room.model.RoomSummary import timber.log.Timber @@ -90,6 +94,7 @@ class HomeDetailFragment : @Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var spaceStateHandler: SpaceStateHandler @Inject lateinit var vectorLocale: VectorLocaleProvider + @Inject lateinit var spaceBarController: SpaceBarController private val DEBUG_VIEW_PAGER = DbgUtil.isDbgEnabled(DbgUtil.DBG_VIEW_PAGER) private val viewPagerDimber = Dimber("Home pager", DbgUtil.DBG_VIEW_PAGER) @@ -145,6 +150,16 @@ class HomeDetailFragment : private val currentCallsViewPresenter = CurrentCallsViewPresenter() + private val spaceBarListener = object: SpaceBarController.SpaceBarListener { + override fun onSpaceBarSelectSpace(space: RoomSummary?) { + spaceStateHandler.setCurrentSpace(space?.roomId, from = SelectSpaceFrom.SELECT) + } + override fun onSpaceBarLongPressSpace(space: RoomSummary?): Boolean { + sharedActionViewModel.post(HomeActivitySharedAction.OpenDrawer) + return true + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) sharedActionViewModel = activityViewModelProvider.get(HomeSharedActionViewModel::class.java) @@ -156,6 +171,12 @@ class HomeDetailFragment : checkNotificationTabStatus() + val spaceBarAdapter = spaceBarController.also { controller -> + controller.spaceRoomListener = spaceBarListener + }.adapter + views.spaceBarRecyclerView.layoutManager = LinearLayoutManager(context) + views.spaceBarRecyclerView.adapter = spaceBarAdapter + // Reduce sensitivity of viewpager to avoid scrolling horizontally by accident too easily views.roomListContainerPager.reduceDragSensitivity(4) @@ -226,7 +247,9 @@ class HomeDetailFragment : highlighted = state.otherSpacesUnread.isHighlight, unread = state.otherSpacesUnread.unreadCount, markedUnread = false - ) + ).also { + spaceBarController.submitHomeUnreadCounts(it) + } ) } @@ -239,7 +262,7 @@ class HomeDetailFragment : // Uninitialized return@onEach } - setupViewPager(selectedSpace, rootSpacesOrdered, currentTab) + setupViewPager(selectedSpace, rootSpacesOrdered , currentTab) previousSelectedSpacePair = selectedSpace } @@ -280,6 +303,9 @@ class HomeDetailFragment : override fun onDestroyView() { currentCallsViewPresenter.unBind() + + spaceBarController.spaceRoomListener = null + super.onDestroyView() } @@ -433,6 +459,9 @@ class HomeDetailFragment : } private fun onSpaceChange(spaceSummary: RoomSummary?) { + if (pagerPagingEnabled) { + spaceBarController.selectSpace(spaceSummary) + } if (spaceSummary == null) { views.groupToolbarSpaceTitleView.isVisible = false } else { @@ -441,6 +470,11 @@ class HomeDetailFragment : } } + private fun setCurrentPagerItem(index: Int, smoothScroll: Boolean) { + views.roomListContainerPager.setCurrentItem(index, smoothScroll) + spaceBarController.scrollToSpacePosition(index) + } + private fun setupKeysBackupBanner() { serverBackupStatusViewModel.handle(ServerBackupStatusAction.OnBannerDisplayed) serverBackupStatusViewModel @@ -556,6 +590,12 @@ class HomeDetailFragment : getPageIndexForSpaceId(selectedSpaceId, unsafeSpaces) } val pagingEnabled = pagingAllowed && unsafeSpaces.isNotEmpty() && selectedIndex != null + if (pagingEnabled) { + views.spaceBarRecyclerView.isVisible = true + spaceBarController.submitData(SpaceBarData(spaces?.let { listOf(null) + it }, selectedSpace)) + } else { + views.spaceBarRecyclerView.isVisible = false + } val safeSpaces = if (pagingEnabled) unsafeSpaces else listOf() // Check if we need to recreate the adapter for a new tab if (oldAdapter != null) { @@ -582,7 +622,7 @@ class HomeDetailFragment : // Do not smooth scroll large distances to avoid loading unnecessary many room lists val diff = selectedIndex - views.roomListContainerPager.currentItem val smoothScroll = abs(diff) <= 1 - views.roomListContainerPager.setCurrentItem(selectedIndex, smoothScroll) + setCurrentPagerItem(selectedIndex, smoothScroll) } } return @@ -656,7 +696,7 @@ class HomeDetailFragment : } try { viewPagerDimber.i{"Home pager: set initial page $selectedIndex"} - views.roomListContainerPager.setCurrentItem(selectedIndex ?: 0, false) + setCurrentPagerItem(selectedIndex ?: 0, false) initialPageSelected = true } catch (e: Exception) { Timber.e("Home pager: Could not set initial page after creating adapter: $e") diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarController.kt new file mode 100644 index 0000000000..698b19c4bd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarController.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2022 SpiritCroc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.list.home.spacebar + +import androidx.recyclerview.widget.LinearLayoutManager +import com.airbnb.epoxy.Carousel +import com.airbnb.epoxy.CarouselModelBuilder +import com.airbnb.epoxy.EpoxyController +import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.carousel +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.util.toMatrixItem +import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min + +class SpaceBarController @Inject constructor( + val stringProvider: StringProvider, + private val avatarRenderer: AvatarRenderer, +) : EpoxyController() { + + private var data: SpaceBarData = SpaceBarData() + private var topLevelUnreadCounts: UnreadCounterBadgeView.State.Count = UnreadCounterBadgeView.State.Count(0, false, 0, false) + + interface SpaceBarListener { + fun onSpaceBarSelectSpace(space: RoomSummary?) + fun onSpaceBarLongPressSpace(space: RoomSummary?): Boolean + } + var spaceRoomListener: SpaceBarListener? = null + + private var carousel: Carousel? = null + + override fun buildModels() { + val host = this + data.spaces?.let { + addSpaces(host, it, data.selectedSpace) + } + } + + private fun addSpaces(host: SpaceBarController, spaces: List, selectedSpace: RoomSummary?) { + carousel { + id("spaces_carousel") + padding( + Carousel.Padding( + 0, + 0, + 0, + 0, + 0, + ) + ) + onBind { _, view, _ -> + host.carousel = view + host.scrollToSpace(selectedSpace?.roomId) + } + + onUnbind { _, _ -> + host.carousel = null + } + + withModelsFrom(spaces) { spaceSummary -> + val onClick = host.spaceRoomListener?.let { it::onSpaceBarSelectSpace } + val onLongClick = host.spaceRoomListener?.let { it::onSpaceBarLongPressSpace } + + if (spaceSummary == null) { + SpaceBarItem_() + .id("de.spiritcroc.riotx.spacebarhome") + .avatarRenderer(host.avatarRenderer) + .matrixItem(null) + .unreadNotificationCount(host.topLevelUnreadCounts.count) + .showHighlighted(host.topLevelUnreadCounts.highlighted) + .unreadCount(host.topLevelUnreadCounts.unread) + .markedUnread(host.topLevelUnreadCounts.markedUnread) + .selected(selectedSpace == null) + .itemLongClickListener { _ -> onLongClick?.invoke(null) ?: false } + .itemClickListener { onClick?.invoke(null) } + } else { + SpaceBarItem_() + .id(spaceSummary.roomId) + .avatarRenderer(host.avatarRenderer) + .matrixItem(spaceSummary.toMatrixItem()) + .unreadNotificationCount(spaceSummary.notificationCount) + .showHighlighted(spaceSummary.highlightCount > 0) + .unreadCount(spaceSummary.unreadCount ?: 0) + .markedUnread(spaceSummary.markedUnread) + .selected(spaceSummary.roomId == selectedSpace?.roomId) + .itemLongClickListener { _ -> onLongClick?.invoke(spaceSummary) ?: false } + .itemClickListener { onClick?.invoke(spaceSummary) } + } + } + } + } + + fun submitData(data: SpaceBarData) { + this.data = data + requestModelBuild() + } + + fun submitHomeUnreadCounts(counts: UnreadCounterBadgeView.State.Count) { + this.topLevelUnreadCounts = counts + requestModelBuild() + } + + fun selectSpace(space: RoomSummary?) { + this.data = this.data.copy(selectedSpace = space) + requestModelBuild() + scrollToSpace(space?.roomId) + } + + fun scrollToSpace(spaceId: String?) { + val position = this.data.spaces?.indexOfFirst { spaceId == it?.roomId } ?: -1 + if (position >= 0) { + scrollToSpacePosition(position) + } + } + + fun scrollToSpacePosition(position: Int) { + val safeCarousel = carousel ?: return + var effectivePosition = position + val lm = safeCarousel.layoutManager as? LinearLayoutManager + if (lm != null) { + // Look-ahead of 1 + if (lm.findFirstCompletelyVisibleItemPosition() >= position) { + effectivePosition-- + } else if (lm.findLastVisibleItemPosition() <= position) { + effectivePosition++ + } + effectivePosition = max(0, min(effectivePosition, lm.itemCount-1)) + } + safeCarousel.smoothScrollToPosition(effectivePosition) + } +} + +private inline fun CarouselModelBuilder.withModelsFrom( + items: List, + modelBuilder: (T) -> EpoxyModel<*> +) { + models(items.map { modelBuilder(it) }) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarData.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarData.kt new file mode 100644 index 0000000000..438d82bcb0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarData.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2022 SpiritCroc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.list.home.spacebar + +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +data class SpaceBarData( + val spaces: List? = null, + val selectedSpace: RoomSummary? = null, +) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarItem.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarItem.kt new file mode 100644 index 0000000000..8d9c52059c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/spacebar/SpaceBarItem.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2022 SpiritCroc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.list.home.spacebar + +import android.content.res.ColorStateList +import android.view.HapticFeedbackConstants +import android.view.View +import android.widget.ImageView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.epoxy.onClick +import im.vector.app.core.platform.CheckableConstraintLayout +import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import im.vector.app.features.themes.ThemeUtils +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass +abstract class SpaceBarItem : VectorEpoxyModel(R.layout.space_bar_item) { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute var matrixItem: MatrixItem? = null + @EpoxyAttribute var unreadNotificationCount: Int = 0 + @EpoxyAttribute var unreadCount: Int = 0 + @EpoxyAttribute var markedUnread: Boolean = false + @EpoxyAttribute var showHighlighted: Boolean = false + @EpoxyAttribute var selected: Boolean = false + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var itemLongClickListener: View.OnLongClickListener? = null + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var itemClickListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + + holder.rootView.onClick(itemClickListener) + holder.rootView.setOnLongClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + itemLongClickListener?.onLongClick(it) ?: false + } + + matrixItem.let { + if (it == null) { + holder.avatarImageView.setImageResource(R.drawable.ic_space_home) + holder.avatarImageView.imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(holder.avatarImageView.context, R.attr.vctr_content_primary)) + holder.avatarImageView.contentDescription = holder.rootView.context.getString(R.string.group_details_home) + } else { + holder.avatarImageView.imageTintList = null + avatarRenderer.render(it, holder.avatarImageView) + holder.avatarImageView.contentDescription = it.getBestName() + } + } + holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State.Count(unreadNotificationCount, showHighlighted, unreadCount, markedUnread)) + holder.rootView.isChecked = selected + } + + override fun unbind(holder: Holder) { + holder.rootView.setOnClickListener(null) + holder.rootView.setOnLongClickListener(null) + avatarRenderer.clear(holder.avatarImageView) + super.unbind(holder) + } + + class Holder : VectorEpoxyHolder() { + val unreadCounterBadgeView by bind(R.id.spaceUnreadCounterBadgeView) + val avatarImageView by bind(R.id.spaceImageView) + val rootView by bind(R.id.spaceRoot) + } +} diff --git a/vector/src/main/res/drawable/bg_bottom_space_item.xml b/vector/src/main/res/drawable/bg_bottom_space_item.xml new file mode 100644 index 0000000000..9669fd8dcb --- /dev/null +++ b/vector/src/main/res/drawable/bg_bottom_space_item.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_home_detail.xml b/vector/src/main/res/layout/fragment_home_detail.xml index 2cf3bdb940..97af157fc5 100644 --- a/vector/src/main/res/layout/fragment_home_detail.xml +++ b/vector/src/main/res/layout/fragment_home_detail.xml @@ -150,7 +150,7 @@ android:id="@+id/roomListContainerStateView" android:layout_width="match_parent" android:layout_height="0dp" - app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView" + app:layout_constraintBottom_toTopOf="@+id/spaceBarRecyclerView" app:layout_constraintTop_toBottomOf="@+id/homeKeysBackupBanner"> + + + + + + + + + + + + + + + +