Merge branch 'feature/room_list' into develop

This commit is contained in:
ganfra
2019-01-31 12:15:43 +01:00
52 changed files with 1095 additions and 207 deletions

View File

@ -11,6 +11,19 @@ androidExtensions {
experimental = true
}
def versionMajor = 0
def versionMinor = 1
def versionPatch = 0
def generateVersionCodeFromTimestamp() {
// It's unix timestamp divided by 10: It's incremented by one every 10 seconds.
return (System.currentTimeMillis() / 1_000 / 10).toInteger()
}
def generateVersionCodeFromVersionName() {
return versionMajor * 10000 + versionMinor * 100 + versionPatch
}
android {
compileSdkVersion 28
defaultConfig {
@ -18,8 +31,8 @@ android {
minSdkVersion 16
targetSdkVersion 28
multiDexEnabled true
versionCode 1
versionName "1.0"
versionCode generateVersionCodeFromTimestamp()
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {

View File

@ -16,10 +16,11 @@
package im.vector.riotredesign.core.epoxy
import android.view.View
import androidx.annotation.IdRes
import androidx.annotation.LayoutRes
import android.view.View
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.OnModelVisibilityStateChangedListener
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
@ -29,6 +30,7 @@ abstract class KotlinModel(
private var view: View? = null
private var onBindCallback: (() -> Unit)? = null
private var onModelVisibilityStateChangedListener: OnModelVisibilityStateChangedListener<KotlinModel, View>? = null
abstract fun bind()
@ -47,6 +49,16 @@ abstract class KotlinModel(
return this
}
override fun onVisibilityStateChanged(visibilityState: Int, view: View) {
onModelVisibilityStateChangedListener?.onVisibilityStateChanged(this, view, visibilityState)
super.onVisibilityStateChanged(visibilityState, view)
}
fun setOnVisibilityStateChanged(listener: OnModelVisibilityStateChangedListener<KotlinModel, View>): KotlinModel {
this.onModelVisibilityStateChangedListener = listener
return this
}
override fun getDefaultLayout() = layoutRes
protected fun <V : View> bind(@IdRes id: Int) = object : ReadOnlyProperty<KotlinModel, V> {
@ -56,7 +68,7 @@ abstract class KotlinModel(
// be optimized with a map
@Suppress("UNCHECKED_CAST")
return view?.findViewById(id) as V?
?: throw IllegalStateException("View ID $id for '${property.name}' not found.")
?: throw IllegalStateException("View ID $id for '${property.name}' not found.")
}
}
}

View File

@ -0,0 +1,38 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * 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.riotredesign.core.extensions
/**
* Returns the last element yielding the smallest value of the given function or `null` if there are no elements.
*/
public inline fun <T, R : Comparable<R>> Iterable<T>.lastMinBy(selector: (T) -> R): T? {
val iterator = iterator()
if (!iterator.hasNext()) return null
var minElem = iterator.next()
var minValue = selector(minElem)
while (iterator.hasNext()) {
val e = iterator.next()
val v = selector(e)
if (minValue >= v) {
minElem = e
minValue = v
}
}
return minElem
}

View File

@ -27,6 +27,8 @@ import im.vector.riotredesign.features.home.room.detail.timeline.TimelineDateFor
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.list.RoomSummaryComparator
import im.vector.riotredesign.features.home.room.list.RoomSummaryController
import org.koin.dsl.module.module
class HomeModule {
@ -65,6 +67,10 @@ class HomeModule {
HomeNavigator()
}
factory {
RoomSummaryController(get())
}
factory { (roomId: String) ->
TimelineEventController(roomId, get(), get(), get())
}
@ -85,6 +91,10 @@ class HomeModule {
HomePermalinkHandler(get())
}
single {
RoomSummaryComparator()
}
}
}

View File

@ -16,9 +16,12 @@
package im.vector.riotredesign.features.home.room.detail
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
sealed class RoomDetailActions {
data class SendMessage(val text: String) : RoomDetailActions()
object IsDisplayed : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent, val index: Int) : RoomDetailActions()
}

View File

@ -23,9 +23,11 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.ToolbarConfigurable
@ -75,7 +77,7 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
override fun onResume() {
super.onResume()
roomDetailViewModel.accept(RoomDetailActions.IsDisplayed)
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
}
private fun setupToolbar() {
@ -86,6 +88,8 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
}
private fun setupRecyclerView() {
val epoxyVisibilityTracker = EpoxyVisibilityTracker()
epoxyVisibilityTracker.attach(recyclerView)
val layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true)
scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager)
recyclerView.layoutManager = layoutManager
@ -100,7 +104,7 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
val textMessage = composerEditText.text.toString()
if (textMessage.isNotBlank()) {
composerEditText.text = null
roomDetailViewModel.accept(RoomDetailActions.SendMessage(textMessage))
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage))
}
}
}
@ -143,4 +147,8 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
homePermalinkHandler.launch(url)
}
override fun onEventVisible(event: TimelineEvent, index: Int) {
roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event, index))
}
}

View File

@ -18,14 +18,18 @@ package im.vector.riotredesign.features.home.room.detail
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.extensions.lastMinBy
import im.vector.riotredesign.core.platform.RiotViewModel
import im.vector.riotredesign.features.home.room.VisibleRoomHolder
import io.reactivex.rxkotlin.subscribeBy
import org.koin.android.ext.android.get
import java.util.concurrent.TimeUnit
class RoomDetailViewModel(initialState: RoomDetailViewState,
private val session: Session,
@ -36,6 +40,8 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
private val roomId = initialState.roomId
private val eventId = initialState.eventId
private val displayedEventsObservable = BehaviorRelay.create<RoomDetailActions.EventDisplayed>()
companion object : MvRxViewModelFactory<RoomDetailViewModel, RoomDetailViewState> {
@JvmStatic
@ -49,13 +55,15 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
init {
observeRoomSummary()
observeTimeline()
observeDisplayedEvents()
room.loadRoomMembersIfNeeded()
}
fun accept(action: RoomDetailActions) {
fun process(action: RoomDetailActions) {
when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> visibleRoomHolder.setVisibleRoom(roomId)
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
}
}
@ -65,6 +73,29 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {})
}
private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {
displayedEventsObservable.accept(action)
}
private fun handleIsDisplayed() {
visibleRoomHolder.setVisibleRoom(roomId)
}
private fun observeDisplayedEvents() {
// We are buffering scroll events for one second
// and keep the most recent one to set the read receipt on.
displayedEventsObservable.hide()
.buffer(1, TimeUnit.SECONDS)
.filter { it.isNotEmpty() }
.subscribeBy(onNext = { actions ->
val mostRecentEvent = actions.lastMinBy { it.index }
mostRecentEvent?.event?.root?.eventId?.let { eventId ->
room.setReadReceipt(eventId, callback = object : MatrixCallback<Void> {})
}
})
.disposeOnClear()
}
private fun observeRoomSummary() {
room.rx().liveRoomSummary()
.execute { async ->

View File

@ -16,12 +16,16 @@
package im.vector.riotredesign.features.home.room.detail.timeline
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyAsyncUtil
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.OnModelVisibilityStateChangedListener
import com.airbnb.epoxy.VisibilityState
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.TimelineData
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.KotlinModel
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.LoadingItemModel_
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
@ -74,6 +78,11 @@ class TimelineEventController(private val roomId: String,
timelineItemFactory.create(event, nextEvent, callback)?.also {
it.id(event.localId)
it.setOnVisibilityStateChanged(OnModelVisibilityStateChangedListener<KotlinModel, View> { model, view, visibilityState ->
if (visibilityState == VisibilityState.VISIBLE) {
callback?.onEventVisible(event, currentPosition)
}
})
epoxyModels.add(it)
}
if (addDaySeparator) {
@ -98,6 +107,7 @@ class TimelineEventController(private val roomId: String,
interface Callback {
fun onEventVisible(event: TimelineEvent, index: Int)
fun onUrlClicked(url: String)
}

View File

@ -16,9 +16,9 @@
package im.vector.riotredesign.features.home.room.detail.timeline
import com.airbnb.epoxy.EpoxyModel
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.KotlinModel
class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
private val roomNameItemFactory: RoomNameItemFactory,
@ -28,14 +28,14 @@ class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
callback: TimelineEventController.Callback?): EpoxyModel<*>? {
callback: TimelineEventController.Callback?): KotlinModel? {
return when (event.root.type) {
EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, callback)
EventType.STATE_ROOM_NAME -> roomNameItemFactory.create(event)
EventType.STATE_ROOM_TOPIC -> roomTopicItemFactory.create(event)
EventType.STATE_ROOM_MEMBER -> roomMemberItemFactory.create(event)
else -> defaultItemFactory.create(event)
else -> defaultItemFactory.create(event)
}
}

View File

@ -26,9 +26,12 @@ import im.vector.riotredesign.core.epoxy.KotlinModel
data class RoomCategoryItem(
val title: CharSequence,
val isExpanded: Boolean,
val unreadCount: Int,
val showHighlighted: Boolean,
val listener: (() -> Unit)? = null
) : KotlinModel(R.layout.item_room_category) {
private val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomCategoryUnreadCounterBadgeView)
private val titleView by bind<TextView>(R.id.roomCategoryTitleView)
private val rootView by bind<ViewGroup>(R.id.roomCategoryRootView)
@ -41,6 +44,7 @@ data class RoomCategoryItem(
val expandedArrowDrawable = ContextCompat.getDrawable(rootView.context, expandedArrowDrawableRes)?.also {
DrawableCompat.setTint(it, tintColor)
}
unreadCounterBadgeView.render(unreadCount, showHighlighted)
titleView.setCompoundDrawablesWithIntrinsicBounds(expandedArrowDrawable, null, null, null)
titleView.text = title
rootView.setOnClickListener { listener?.invoke() }

View File

@ -22,7 +22,6 @@ import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Incomplete
import com.airbnb.mvrx.Success
@ -30,7 +29,6 @@ import com.airbnb.mvrx.activityViewModel
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.hideKeyboard
import im.vector.riotredesign.core.extensions.setupAsSearch
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.StateView
@ -47,8 +45,8 @@ class RoomListFragment : RiotFragment(), RoomSummaryController.Callback {
}
private val homeNavigator by inject<HomeNavigator>()
private val roomController by inject<RoomSummaryController>()
private val homeViewModel: RoomListViewModel by activityViewModel()
private lateinit var roomController: RoomSummaryController
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_room_list, container, false)
@ -56,7 +54,7 @@ class RoomListFragment : RiotFragment(), RoomSummaryController.Callback {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
roomController = RoomSummaryController(this)
roomController.callback = this
stateView.contentView = epoxyRecyclerView
epoxyRecyclerView.setController(roomController)
setupFilterView()

View File

@ -24,6 +24,7 @@ import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.RiotViewModel
import im.vector.riotredesign.features.home.group.SelectedGroupHolder
@ -32,6 +33,7 @@ import io.reactivex.Observable
import io.reactivex.functions.Function3
import io.reactivex.rxkotlin.subscribeBy
import org.koin.android.ext.android.get
import java.util.concurrent.TimeUnit
typealias RoomListFilterName = CharSequence
@ -39,7 +41,8 @@ class RoomListViewModel(initialState: RoomListViewState,
private val session: Session,
private val selectedGroupHolder: SelectedGroupHolder,
private val visibleRoomHolder: VisibleRoomHolder,
private val roomSelectionRepository: RoomSelectionRepository)
private val roomSelectionRepository: RoomSelectionRepository,
private val roomSummaryComparator: RoomSummaryComparator)
: RiotViewModel<RoomListViewState>(initialState) {
companion object : MvRxViewModelFactory<RoomListViewModel, RoomListViewState> {
@ -50,7 +53,8 @@ class RoomListViewModel(initialState: RoomListViewState,
val roomSelectionRepository = viewModelContext.activity.get<RoomSelectionRepository>()
val selectedGroupHolder = viewModelContext.activity.get<SelectedGroupHolder>()
val visibleRoomHolder = viewModelContext.activity.get<VisibleRoomHolder>()
return RoomListViewModel(state, currentSession, selectedGroupHolder, visibleRoomHolder, roomSelectionRepository)
val roomSummaryComparator = viewModelContext.activity.get<RoomSummaryComparator>()
return RoomListViewModel(state, currentSession, selectedGroupHolder, visibleRoomHolder, roomSelectionRepository, roomSummaryComparator)
}
}
@ -92,19 +96,11 @@ class RoomListViewModel(initialState: RoomListViewState,
private fun observeRoomSummaries() {
Observable.combineLatest<List<RoomSummary>, Option<GroupSummary>, Option<RoomListFilterName>, RoomSummaries>(
session.rx().liveRoomSummaries(),
session.rx().liveRoomSummaries().throttleLast(300, TimeUnit.MILLISECONDS),
selectedGroupHolder.selectedGroup(),
roomListFilter,
roomListFilter.throttleLast(300, TimeUnit.MILLISECONDS),
Function3 { rooms, selectedGroupOption, filterRoomOption ->
val filterRoom = filterRoomOption.orNull()
val filteredRooms = rooms.filter {
if (filterRoom.isNullOrBlank()) {
true
} else {
it.displayName.contains(other = filterRoom, ignoreCase = true)
}
}
val filteredRooms = filterRooms(rooms, filterRoomOption)
val selectedGroup = selectedGroupOption.orNull()
val filteredDirectRooms = filteredRooms
.filter { it.isDirect }
@ -123,7 +119,7 @@ class RoomListViewModel(initialState: RoomListViewState,
.filter {
selectedGroup?.roomIds?.contains(it.roomId) ?: true
}
RoomSummaries(filteredDirectRooms, filteredGroupRooms)
buildRoomSummaries(filteredDirectRooms + filteredGroupRooms)
}
)
.execute { async ->
@ -132,4 +128,44 @@ class RoomListViewModel(initialState: RoomListViewState,
)
}
}
private fun filterRooms(rooms: List<RoomSummary>, filterRoomOption: Option<RoomListFilterName>): List<RoomSummary> {
val filterRoom = filterRoomOption.orNull()
return rooms.filter {
if (filterRoom.isNullOrBlank()) {
true
} else {
it.displayName.contains(other = filterRoom, ignoreCase = true)
}
}
}
private fun buildRoomSummaries(rooms: List<RoomSummary>): RoomSummaries {
val favourites = ArrayList<RoomSummary>()
val directChats = ArrayList<RoomSummary>()
val groupRooms = ArrayList<RoomSummary>()
val lowPriorities = ArrayList<RoomSummary>()
val serverNotices = ArrayList<RoomSummary>()
for (room in rooms) {
val tags = room.tags.map { it.name }
when {
tags.contains(RoomTag.ROOM_TAG_SERVER_NOTICE) -> serverNotices.add(room)
tags.contains(RoomTag.ROOM_TAG_FAVOURITE) -> favourites.add(room)
tags.contains(RoomTag.ROOM_TAG_LOW_PRIORITY) -> lowPriorities.add(room)
room.isDirect -> directChats.add(room)
else -> groupRooms.add(room)
}
}
return RoomSummaries(
favourites = favourites.sortedWith(roomSummaryComparator),
directRooms = directChats.sortedWith(roomSummaryComparator),
groupRooms = groupRooms.sortedWith(roomSummaryComparator),
lowPriorities = lowPriorities.sortedWith(roomSummaryComparator),
serverNotices = serverNotices.sortedWith(roomSummaryComparator)
)
}
}

View File

@ -27,10 +27,13 @@ data class RoomListViewState(
) : MvRxState
data class RoomSummaries(
val favourites: List<RoomSummary>,
val directRooms: List<RoomSummary>,
val groupRooms: List<RoomSummary>
val groupRooms: List<RoomSummary>,
val lowPriorities: List<RoomSummary>,
val serverNotices: List<RoomSummary>
)
fun RoomSummaries?.isNullOrEmpty(): Boolean {
return this == null || (directRooms.isEmpty() && groupRooms.isEmpty())
return this == null || (directRooms.isEmpty() && groupRooms.isEmpty() && favourites.isEmpty() && lowPriorities.isEmpty() && serverNotices.isEmpty())
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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.riotredesign.features.home.room.list
import im.vector.matrix.android.api.session.room.model.RoomSummary
class RoomSummaryComparator
: Comparator<RoomSummary> {
override fun compare(leftRoomSummary: RoomSummary?, rightRoomSummary: RoomSummary?): Int {
val retValue: Int
var leftHighlightCount = 0
var rightHighlightCount = 0
var leftNotificationCount = 0
var rightNotificationCount = 0
var rightTimestamp = 0L
var leftTimestamp = 0L
if (null != leftRoomSummary) {
leftHighlightCount = leftRoomSummary.highlightCount
leftNotificationCount = leftRoomSummary.notificationCount
leftTimestamp = leftRoomSummary.lastMessage?.originServerTs ?: 0
}
if (null != rightRoomSummary) {
rightHighlightCount = rightRoomSummary.highlightCount
rightNotificationCount = rightRoomSummary.notificationCount
rightTimestamp = rightRoomSummary.lastMessage?.originServerTs ?: 0
}
if (leftRoomSummary?.lastMessage == null) {
retValue = 1
} else if (rightRoomSummary?.lastMessage == null) {
retValue = -1
} else if (rightHighlightCount > 0 && leftHighlightCount == 0) {
retValue = 1
} else if (rightHighlightCount == 0 && leftHighlightCount > 0) {
retValue = -1
} else if (rightNotificationCount > 0 && leftNotificationCount == 0) {
retValue = 1
} else if (rightNotificationCount == 0 && leftNotificationCount > 0) {
retValue = -1
} else {
val deltaTimestamp = rightTimestamp - leftTimestamp
if (deltaTimestamp > 0) {
retValue = 1
} else if (deltaTimestamp < 0) {
retValue = -1
} else {
retValue = 0
}
}
return retValue
}
}

View File

@ -16,56 +16,100 @@
package im.vector.riotredesign.features.home.room.list
import androidx.annotation.StringRes
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
class RoomSummaryController(private val callback: Callback? = null
class RoomSummaryController(private val stringProvider: StringProvider
) : TypedEpoxyController<RoomListViewState>() {
private var isDirectRoomsExpanded = true
private var isGroupRoomsExpanded = true
private var isFavoriteRoomsExpanded = true
private var isDirectRoomsExpanded = false
private var isGroupRoomsExpanded = false
private var isLowPriorityRoomsExpanded = false
private var isServerNoticeRoomsExpanded = false
var callback: Callback? = null
override fun buildModels(viewState: RoomListViewState) {
val roomSummaries = viewState.asyncRooms()
RoomCategoryItem(
title = "DIRECT MESSAGES",
isExpanded = isDirectRoomsExpanded,
listener = {
isDirectRoomsExpanded = !isDirectRoomsExpanded
setData(viewState)
}
)
.id("direct_messages")
.addTo(this)
if (isDirectRoomsExpanded) {
buildRoomModels(roomSummaries?.directRooms ?: emptyList(), viewState.selectedRoomId)
val favourites = roomSummaries?.favourites ?: emptyList()
buildRoomCategory(viewState, favourites, R.string.room_list_favourites, isFavoriteRoomsExpanded) {
isFavoriteRoomsExpanded = !isFavoriteRoomsExpanded
}
if (isFavoriteRoomsExpanded) {
buildRoomModels(favourites, viewState.selectedRoomId)
}
RoomCategoryItem(
title = "GROUPS",
isExpanded = isGroupRoomsExpanded,
listener = {
isGroupRoomsExpanded = !isGroupRoomsExpanded
setData(viewState)
}
)
.id("group_messages")
.addTo(this)
val directRooms = roomSummaries?.directRooms ?: emptyList()
buildRoomCategory(viewState, directRooms, R.string.room_list_direct, isDirectRoomsExpanded) {
isDirectRoomsExpanded = !isDirectRoomsExpanded
}
if (isDirectRoomsExpanded) {
buildRoomModels(directRooms, viewState.selectedRoomId)
}
val groupRooms = roomSummaries?.groupRooms ?: emptyList()
buildRoomCategory(viewState, groupRooms, R.string.room_list_group, isGroupRoomsExpanded) {
isGroupRoomsExpanded = !isGroupRoomsExpanded
}
if (isGroupRoomsExpanded) {
buildRoomModels(roomSummaries?.groupRooms ?: emptyList(), viewState.selectedRoomId)
buildRoomModels(groupRooms, viewState.selectedRoomId)
}
val lowPriorities = roomSummaries?.lowPriorities ?: emptyList()
buildRoomCategory(viewState, lowPriorities, R.string.room_list_low_priority, isLowPriorityRoomsExpanded) {
isLowPriorityRoomsExpanded = !isLowPriorityRoomsExpanded
}
if (isLowPriorityRoomsExpanded) {
buildRoomModels(lowPriorities, viewState.selectedRoomId)
}
val serverNotices = roomSummaries?.serverNotices ?: emptyList()
buildRoomCategory(viewState, serverNotices, R.string.room_list_system_alert, isServerNoticeRoomsExpanded) {
isServerNoticeRoomsExpanded = !isServerNoticeRoomsExpanded
}
if (isServerNoticeRoomsExpanded) {
buildRoomModels(serverNotices, viewState.selectedRoomId)
}
}
private fun buildRoomCategory(viewState: RoomListViewState, summaries: List<RoomSummary>, @StringRes titleRes: Int, isExpanded: Boolean, mutateExpandedState: () -> Unit) {
//TODO should add some business logic later
val unreadCount = if (summaries.isEmpty()) {
0
} else {
summaries.map { it.notificationCount }.reduce { acc, i -> acc + i }
}
val showHighlighted = summaries.any { it.highlightCount > 0 }
RoomCategoryItem(
title = stringProvider.getString(titleRes).toUpperCase(),
isExpanded = isExpanded,
unreadCount = unreadCount,
showHighlighted = showHighlighted,
listener = {
mutateExpandedState()
setData(viewState)
}
)
.id(titleRes)
.addTo(this)
}
private fun buildRoomModels(summaries: List<RoomSummary>, selectedRoomId: String?) {
summaries.forEach { roomSummary ->
val unreadCount = roomSummary.notificationCount
val showHighlighted = roomSummary.highlightCount > 0
val isSelected = roomSummary.roomId == selectedRoomId
RoomSummaryItem(
roomName = roomSummary.displayName,
avatarUrl = roomSummary.avatarUrl,
isSelected = isSelected,
showHighlighted = showHighlighted,
unreadCount = unreadCount,
listener = { callback?.onRoomSelected(roomSummary) }
)
.id(roomSummary.roomId)

View File

@ -0,0 +1,35 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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.riotredesign.features.home.room.list
object RoomSummaryFormatter {
/**
* Format the unread messages counter.
*
* @param count the count
* @return the formatted value
*/
fun formatUnreadMessagesCounter(count: Int): String {
return if (count > 999) {
"${count / 1000}.${count % 1000 / 100}K"
} else {
count.toString()
}
}
}

View File

@ -28,14 +28,18 @@ data class RoomSummaryItem(
val roomName: CharSequence,
val avatarUrl: String?,
val isSelected: Boolean,
val unreadCount: Int,
val showHighlighted: Boolean,
val listener: (() -> Unit)? = null
) : KotlinModel(R.layout.item_room) {
private val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomUnreadCounterBadgeView)
private val titleView by bind<TextView>(R.id.roomNameView)
private val avatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
private val rootView by bind<CheckableFrameLayout>(R.id.itemRoomLayout)
override fun bind() {
unreadCounterBadgeView.render(unreadCount, showHighlighted)
rootView.isChecked = isSelected
rootView.setOnClickListener { listener?.invoke() }
titleView.text = roomName

View File

@ -0,0 +1,52 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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.riotredesign.features.home.room.list
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import im.vector.riotredesign.R
class UnreadCounterBadgeView : AppCompatTextView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
fun render(count: Int, highlighted: Boolean) {
if (count == 0) {
visibility = View.INVISIBLE
} else {
visibility = View.VISIBLE
val bgRes = if (highlighted) {
R.drawable.bg_unread_highlight
} else {
R.drawable.bg_unread_notification
}
setBackgroundResource(bgRes)
text = RoomSummaryFormatter.formatUnreadMessagesCounter(count)
}
}
enum class Status {
NOTIFICATION,
HIGHLIGHT
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/rosy_pink" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/grey_lynch" />
</shape>

View File

@ -1,18 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<im.vector.riotredesign.core.platform.CheckableFrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<im.vector.riotredesign.core.platform.CheckableFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/itemRoomLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="8dp"
android:paddingRight="16dp"
android:foreground="?attr/selectableItemBackground"
android:background="@drawable/bg_room_item"
android:clickable="true"
android:focusable="true">
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:paddingLeft="8dp"
android:paddingRight="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
@ -35,15 +34,34 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:duplicateParentState="true"
android:textColor="@color/color_room_title"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@+id/roomUnreadCounterBadgeView"
app:layout_constraintStart_toEndOf="@id/roomAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />
<im.vector.riotredesign.features.home.room.list.UnreadCounterBadgeView
android:id="@+id/roomUnreadCounterBadgeView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:minWidth="24dp"
android:minHeight="24dp"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:textColor="@android:color/white"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@drawable/bg_unread_highlight"
tools:text="115" />
</androidx.constraintlayout.widget.ConstraintLayout>
</im.vector.riotredesign.core.platform.CheckableFrameLayout>

View File

@ -11,7 +11,7 @@
android:gravity="center_vertical"
android:paddingLeft="16dp"
android:paddingTop="8dp"
android:paddingRight="16dp"
android:paddingRight="8dp"
android:paddingBottom="8dp"
tools:background="@color/pale_grey">
@ -26,16 +26,32 @@
android:text="DIRECT MESSAGES"
android:textColor="@color/bluey_grey_two"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/roomCategoryAddButton"
app:layout_constraintEnd_toStartOf="@+id/roomCategoryUnreadCounterBadgeView"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<im.vector.riotredesign.features.home.room.list.UnreadCounterBadgeView
android:id="@+id/roomCategoryUnreadCounterBadgeView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:minWidth="24dp"
android:minHeight="24dp"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:textColor="@android:color/white"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/roomCategoryAddButton"
app:layout_constraintTop_toTopOf="parent"
tools:background="@drawable/bg_unread_highlight"
tools:text="4" />
<ImageView
android:id="@+id/roomCategoryAddButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerInside"
android:src="@drawable/ic_add_circle_white"
android:tint="@color/bluey_grey_two"

View File

@ -16,4 +16,5 @@
<color name="cool_grey">#a5aab2</color>
<color name="pale_grey_two">#ebedf8</color>
<color name="brown_grey">#a5a5a5</color>
<color name="grey_lynch">#61708B</color>
</resources>

View File

@ -1,9 +1,15 @@
<resources>
<string name="app_name">Riot X</string>
<string name="app_name">"Riot X"</string>
<string name="global_retry">Réessayer</string>
<string name="error_no_network">Pas de connexion internet</string>
<string name="error_common">Une erreur est survenue</string>
<string name="room_list_empty">Rejoignez une room pour commencer à utiliser l\'application</string>
<string name="global_retry">"Retry"</string>
<string name="error_no_network">"No network connection"</string>
<string name="error_common">"An error occurred"</string>
<string name="room_list_empty">"Join a room to start using the app."</string>
<string name="room_list_favourites">"Favourites"</string>
<string name="room_list_direct">"People"</string>
<string name="room_list_group">"Rooms"</string>
<string name="room_list_low_priority">"Low priority"</string>
<string name="room_list_system_alert">"System Alerts"</string>
</resources>