Merge pull request #741 from vector-im/feature/breadcrumbs

Breadcrumbs
This commit is contained in:
Benoit Marty 2019-12-09 14:06:27 +01:00 committed by GitHub
commit e73923dca3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 874 additions and 39 deletions

View File

@ -2,7 +2,7 @@ Changes in RiotX 0.10.0 (2019-XX-XX)
===================================================
Features ✨:
-
- Breadcrumbs: switch from one room to another quickly (#571)
Improvements 🙌:
-

View File

@ -17,7 +17,7 @@ Client request the sign-up flows, once the homeserver is chosen by the user and
}
```
We get the flows with a 401, which also means the the registration is possible on this homeserver.
We get the flows with a 401, which also means that the registration is possible on this homeserver.
```json
{

View File

@ -38,6 +38,10 @@ class RxSession(private val session: Session) {
return session.liveGroupSummaries().asObservable()
}
fun liveBreadcrumbs(): Observable<List<RoomSummary>> {
return session.liveBreadcrumbs().asObservable()
}
fun liveSyncState(): Observable<SyncState> {
return session.syncState().asObservable()
}

View File

@ -54,6 +54,18 @@ interface RoomService {
*/
fun liveRoomSummaries(): LiveData<List<RoomSummary>>
/**
* Get a live list of Breadcrumbs
* @return the [LiveData] of [RoomSummary]
*/
fun liveBreadcrumbs(): LiveData<List<RoomSummary>>
/**
* Inform the Matrix SDK that a room is displayed.
* The SDK will update the breadcrumbs in the user account data
*/
fun onRoomDisplayed(roomId: String): Cancelable
/**
* Mark all rooms as read
*/

View File

@ -19,16 +19,14 @@ package im.vector.matrix.android.internal.crypto.store.db.query
import im.vector.matrix.android.internal.crypto.store.db.model.CryptoRoomEntity
import im.vector.matrix.android.internal.crypto.store.db.model.CryptoRoomEntityFields
import io.realm.Realm
import io.realm.kotlin.createObject
import io.realm.kotlin.where
/**
* Get or create a room
*/
internal fun CryptoRoomEntity.Companion.getOrCreate(realm: Realm, roomId: String): CryptoRoomEntity {
return getById(realm, roomId)
?: let {
realm.createObject(CryptoRoomEntity::class.java, roomId)
}
return getById(realm, roomId) ?: realm.createObject(roomId)
}
/**

View File

@ -20,18 +20,20 @@ import im.vector.matrix.android.internal.crypto.store.db.model.DeviceInfoEntity
import im.vector.matrix.android.internal.crypto.store.db.model.DeviceInfoEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.createPrimaryKey
import io.realm.Realm
import io.realm.kotlin.createObject
import io.realm.kotlin.where
/**
* Get or create a device info
*/
internal fun DeviceInfoEntity.Companion.getOrCreate(realm: Realm, userId: String, deviceId: String): DeviceInfoEntity {
val key = DeviceInfoEntity.createPrimaryKey(userId, deviceId)
return realm.where<DeviceInfoEntity>()
.equalTo(DeviceInfoEntityFields.PRIMARY_KEY, DeviceInfoEntity.createPrimaryKey(userId, deviceId))
.equalTo(DeviceInfoEntityFields.PRIMARY_KEY, key)
.findFirst()
?: let {
realm.createObject(DeviceInfoEntity::class.java, DeviceInfoEntity.createPrimaryKey(userId, deviceId)).apply {
this.deviceId = deviceId
}
}
?: realm.createObject<DeviceInfoEntity>(key)
.apply {
this.deviceId = deviceId
}
}

View File

@ -19,6 +19,7 @@ package im.vector.matrix.android.internal.crypto.store.db.query
import im.vector.matrix.android.internal.crypto.store.db.model.UserEntity
import im.vector.matrix.android.internal.crypto.store.db.model.UserEntityFields
import io.realm.Realm
import io.realm.kotlin.createObject
import io.realm.kotlin.where
/**
@ -28,9 +29,7 @@ internal fun UserEntity.Companion.getOrCreate(realm: Realm, userId: String): Use
return realm.where<UserEntity>()
.equalTo(UserEntityFields.USER_ID, userId)
.findFirst()
?: let {
realm.createObject(UserEntity::class.java, userId)
}
?: realm.createObject(userId)
}
/**

View File

@ -0,0 +1,27 @@
/*
* 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.matrix.android.internal.database.model
import io.realm.RealmList
import io.realm.RealmObject
internal open class BreadcrumbsEntity(
var recentRoomIds: RealmList<String> = RealmList()
) : RealmObject() {
companion object
}

View File

@ -38,7 +38,8 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "",
var readMarkerId: String? = null,
var hasUnreadMessages: Boolean = false,
var tags: RealmList<RoomTagEntity> = RealmList(),
var userDrafts: UserDraftsEntity? = null
var userDrafts: UserDraftsEntity? = null,
var breadcrumbsIndex: Int = NOT_IN_BREADCRUMBS
) : RealmObject() {
private var membershipStr: String = Membership.NONE.name
@ -59,5 +60,7 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "",
versioningStateStr = value.name
}
companion object
companion object {
const val NOT_IN_BREADCRUMBS = -1
}
}

View File

@ -36,6 +36,7 @@ import io.realm.annotations.RealmModule
SyncEntity::class,
UserEntity::class,
IgnoredUserEntity::class,
BreadcrumbsEntity::class,
EventAnnotationsSummaryEntity::class,
ReactionAggregatedSummaryEntity::class,
EditAggregatedSummaryEntity::class,

View File

@ -0,0 +1,30 @@
/*
* 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.matrix.android.internal.database.query
import im.vector.matrix.android.internal.database.model.BreadcrumbsEntity
import io.realm.Realm
import io.realm.kotlin.createObject
import io.realm.kotlin.where
internal fun BreadcrumbsEntity.Companion.get(realm: Realm): BreadcrumbsEntity? {
return realm.where<BreadcrumbsEntity>().findFirst()
}
internal fun BreadcrumbsEntity.Companion.getOrCreate(realm: Realm): BreadcrumbsEntity {
return get(realm) ?: realm.createObject()
}

View File

@ -20,6 +20,7 @@ import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadMarkerEntityFields
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.createObject
import io.realm.kotlin.where
internal fun ReadMarkerEntity.Companion.where(realm: Realm, roomId: String): RealmQuery<ReadMarkerEntity> {
@ -28,6 +29,5 @@ internal fun ReadMarkerEntity.Companion.where(realm: Realm, roomId: String): Rea
}
internal fun ReadMarkerEntity.Companion.getOrCreate(realm: Realm, roomId: String): ReadMarkerEntity {
return where(realm, roomId).findFirst()
?: realm.createObject(ReadMarkerEntity::class.java, roomId)
return where(realm, roomId).findFirst() ?: realm.createObject(roomId)
}

View File

@ -20,6 +20,7 @@ import im.vector.matrix.android.internal.database.model.ReadReceiptEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptEntityFields
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.createObject
import io.realm.kotlin.where
internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String): RealmQuery<ReadReceiptEntity> {
@ -44,10 +45,11 @@ internal fun ReadReceiptEntity.Companion.createUnmanaged(roomId: String, eventId
internal fun ReadReceiptEntity.Companion.getOrCreate(realm: Realm, roomId: String, userId: String): ReadReceiptEntity {
return ReadReceiptEntity.where(realm, roomId, userId).findFirst()
?: realm.createObject(ReadReceiptEntity::class.java, buildPrimaryKey(roomId, userId)).apply {
this.roomId = roomId
this.userId = userId
}
?: realm.createObject<ReadReceiptEntity>(buildPrimaryKey(roomId, userId))
.apply {
this.roomId = roomId
this.userId = userId
}
}
private fun buildPrimaryKey(roomId: String, userId: String) = "${roomId}_$userId"

View File

@ -21,6 +21,7 @@ import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.kotlin.createObject
import io.realm.kotlin.where
internal fun RoomSummaryEntity.Companion.where(realm: Realm, roomId: String? = null): RealmQuery<RoomSummaryEntity> {
@ -32,8 +33,7 @@ internal fun RoomSummaryEntity.Companion.where(realm: Realm, roomId: String? = n
}
internal fun RoomSummaryEntity.Companion.getOrCreate(realm: Realm, roomId: String): RoomSummaryEntity {
return where(realm, roomId).findFirst()
?: realm.createObject(RoomSummaryEntity::class.java, roomId)
return where(realm, roomId).findFirst() ?: realm.createObject(roomId)
}
internal fun RoomSummaryEntity.Companion.getDirectRooms(realm: Realm): RealmResults<RoomSummaryEntity> {

View File

@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.internal.network.parsing.RuntimeJsonAdapterFactory
import im.vector.matrix.android.internal.network.parsing.UriMoshiAdapter
import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData
import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataBreadcrumbs
import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataDirectMessages
import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataFallback
import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataIgnoredUsers
@ -34,6 +35,7 @@ object MoshiProvider {
.registerSubtype(UserAccountDataDirectMessages::class.java, UserAccountData.TYPE_DIRECT_MESSAGES)
.registerSubtype(UserAccountDataIgnoredUsers::class.java, UserAccountData.TYPE_IGNORED_USER_LIST)
.registerSubtype(UserAccountDataPushRules::class.java, UserAccountData.TYPE_PUSH_RULES)
.registerSubtype(UserAccountDataBreadcrumbs::class.java, UserAccountData.TYPE_BREADCRUMBS)
)
.add(RuntimeJsonAdapterFactory.of(MessageContent::class.java, "msgtype", MessageDefaultContent::class.java)
.registerSubtype(MessageTextContent::class.java, MessageType.MSGTYPE_TEXT)

View File

@ -33,6 +33,7 @@ import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.create.CreateRoomTask
import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask
import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask
import im.vector.matrix.android.internal.session.user.accountdata.UpdateBreadcrumbsTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import io.realm.Realm
@ -43,6 +44,7 @@ internal class DefaultRoomService @Inject constructor(private val monarchy: Mona
private val createRoomTask: CreateRoomTask,
private val joinRoomTask: JoinRoomTask,
private val markAllRoomsReadTask: MarkAllRoomsReadTask,
private val updateBreadcrumbsTask: UpdateBreadcrumbsTask,
private val roomFactory: RoomFactory,
private val taskExecutor: TaskExecutor) : RoomService {
@ -75,6 +77,25 @@ internal class DefaultRoomService @Inject constructor(private val monarchy: Mona
)
}
override fun liveBreadcrumbs(): LiveData<List<RoomSummary>> {
return monarchy.findAllMappedWithChanges(
{ realm ->
RoomSummaryEntity.where(realm)
.isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME)
.notEqualTo(RoomSummaryEntityFields.VERSIONING_STATE_STR, VersioningState.UPGRADED_ROOM_JOINED.name)
.greaterThan(RoomSummaryEntityFields.BREADCRUMBS_INDEX, RoomSummaryEntity.NOT_IN_BREADCRUMBS)
.sort(RoomSummaryEntityFields.BREADCRUMBS_INDEX)
},
{ roomSummaryMapper.map(it) }
)
}
override fun onRoomDisplayed(roomId: String): Cancelable {
return updateBreadcrumbsTask
.configureWith(UpdateBreadcrumbsTask.Params(roomId))
.executeBy(taskExecutor)
}
override fun joinRoom(roomId: String, viaServers: List<String>, callback: MatrixCallback<Unit>): Cancelable {
return joinRoomTask
.configureWith(JoinRoomTask.Params(roomId, viaServers)) {

View File

@ -29,6 +29,7 @@ import im.vector.matrix.android.internal.session.room.membership.RoomMembers
import im.vector.matrix.android.internal.session.sync.model.InvitedRoomSync
import im.vector.matrix.android.internal.session.sync.model.accountdata.*
import im.vector.matrix.android.internal.session.user.accountdata.DirectChatsHelper
import im.vector.matrix.android.internal.session.user.accountdata.SaveBreadcrumbsTask
import im.vector.matrix.android.internal.session.user.accountdata.SaveIgnoredUsersTask
import im.vector.matrix.android.internal.session.user.accountdata.UpdateUserAccountDataTask
import im.vector.matrix.android.internal.task.TaskExecutor
@ -44,6 +45,7 @@ internal class UserAccountDataSyncHandler @Inject constructor(private val monarc
private val updateUserAccountDataTask: UpdateUserAccountDataTask,
private val savePushRulesTask: SavePushRulesTask,
private val saveIgnoredUsersTask: SaveIgnoredUsersTask,
private val saveBreadcrumbsTask: SaveBreadcrumbsTask,
private val taskExecutor: TaskExecutor) {
suspend fun handle(accountData: UserAccountDataSync?, invites: Map<String, InvitedRoomSync>?) {
@ -52,6 +54,7 @@ internal class UserAccountDataSyncHandler @Inject constructor(private val monarc
is UserAccountDataDirectMessages -> handleDirectChatRooms(it)
is UserAccountDataPushRules -> handlePushRules(it)
is UserAccountDataIgnoredUsers -> handleIgnoredUsers(it)
is UserAccountDataBreadcrumbs -> handleBreadcrumbs(it)
is UserAccountDataFallback -> Timber.d("Receive account data of unhandled type ${it.type}")
else -> error("Missing code here!")
}
@ -130,4 +133,10 @@ internal class UserAccountDataSyncHandler @Inject constructor(private val monarc
.executeBy(taskExecutor)
// TODO If not initial sync, we should execute a init sync
}
private fun handleBreadcrumbs(userAccountDataBreadcrumbs: UserAccountDataBreadcrumbs) {
saveBreadcrumbsTask
.configureWith(SaveBreadcrumbsTask.Params(userAccountDataBreadcrumbs.content.recentRoomIds))
.executeBy(taskExecutor)
}
}

View File

@ -25,6 +25,7 @@ internal abstract class UserAccountData {
companion object {
const val TYPE_IGNORED_USER_LIST = "m.ignored_user_list"
const val TYPE_DIRECT_MESSAGES = "m.direct"
const val TYPE_BREADCRUMBS = "im.vector.setting.breadcrumbs" // Was previously "im.vector.riot.breadcrumb_rooms"
const val TYPE_PREVIEW_URLS = "org.matrix.preview_urls"
const val TYPE_WIDGETS = "m.widgets"
const val TYPE_PUSH_RULES = "m.push_rules"

View File

@ -0,0 +1,31 @@
/*
* 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.matrix.android.internal.session.sync.model.accountdata
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class UserAccountDataBreadcrumbs(
@Json(name = "type") override val type: String = TYPE_BREADCRUMBS,
@Json(name = "content") val content: BreadcrumbsContent
) : UserAccountData()
@JsonClass(generateAdapter = true)
internal data class BreadcrumbsContent(
@Json(name = "recent_rooms") val recentRoomIds: List<String> = emptyList()
)

View File

@ -35,5 +35,11 @@ internal abstract class AccountDataModule {
}
@Binds
abstract fun bindUpdateUserAccountDataTask(updateUserAccountDataTask: DefaultUpdateUserAccountDataTask): UpdateUserAccountDataTask
abstract fun bindUpdateUserAccountDataTask(task: DefaultUpdateUserAccountDataTask): UpdateUserAccountDataTask
@Binds
abstract fun bindSaveBreadcrumbsTask(task: DefaultSaveBreadcrumbsTask): SaveBreadcrumbsTask
@Binds
abstract fun bindUpdateBreadcrumsTask(task: DefaultUpdateBreadcrumbsTask): UpdateBreadcrumbsTask
}

View File

@ -0,0 +1,67 @@
/*
* 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.matrix.android.internal.session.user.accountdata
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.internal.database.model.BreadcrumbsEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields
import im.vector.matrix.android.internal.database.query.getOrCreate
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.awaitTransaction
import io.realm.RealmList
import javax.inject.Inject
/**
* Save the Breadcrumbs roomId list in DB, either from the sync, or updated locally
*/
internal interface SaveBreadcrumbsTask : Task<SaveBreadcrumbsTask.Params, Unit> {
data class Params(
val recentRoomIds: List<String>
)
}
internal class DefaultSaveBreadcrumbsTask @Inject constructor(
private val monarchy: Monarchy
) : SaveBreadcrumbsTask {
override suspend fun execute(params: SaveBreadcrumbsTask.Params) {
monarchy.awaitTransaction { realm ->
// Get or create a breadcrumbs entity
val entity = BreadcrumbsEntity.getOrCreate(realm)
// And save the new received list
entity.recentRoomIds = RealmList<String>().apply { addAll(params.recentRoomIds) }
// Update the room summaries
// Reset all the indexes...
RoomSummaryEntity.where(realm)
.greaterThan(RoomSummaryEntityFields.BREADCRUMBS_INDEX, RoomSummaryEntity.NOT_IN_BREADCRUMBS)
.findAll()
.forEach {
it.breadcrumbsIndex = RoomSummaryEntity.NOT_IN_BREADCRUMBS
}
// ...and apply new indexes
params.recentRoomIds.forEachIndexed { index, roomId ->
RoomSummaryEntity.where(realm, roomId)
.findFirst()
?.breadcrumbsIndex = index
}
}
}
}

View File

@ -0,0 +1,66 @@
/*
* 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.matrix.android.internal.session.user.accountdata
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.internal.database.model.BreadcrumbsEntity
import im.vector.matrix.android.internal.database.query.get
import im.vector.matrix.android.internal.session.sync.model.accountdata.BreadcrumbsContent
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.fetchCopied
import javax.inject.Inject
// Use the same arbitrary value than Riot-Web
private const val MAX_BREADCRUMBS_ROOMS_NUMBER = 20
internal interface UpdateBreadcrumbsTask : Task<UpdateBreadcrumbsTask.Params, Unit> {
data class Params(
val newTopRoomId: String
)
}
internal class DefaultUpdateBreadcrumbsTask @Inject constructor(
private val saveBreadcrumbsTask: SaveBreadcrumbsTask,
private val updateUserAccountDataTask: UpdateUserAccountDataTask,
private val monarchy: Monarchy
) : UpdateBreadcrumbsTask {
override suspend fun execute(params: UpdateBreadcrumbsTask.Params) {
val newBreadcrumbs =
// Get the breadcrumbs entity, if any
monarchy.fetchCopied { BreadcrumbsEntity.get(it) }
?.recentRoomIds
?.apply {
// Modify the list to add the newTopRoomId first
// Ensure the newTopRoomId is not already in the list
remove(params.newTopRoomId)
// Add the newTopRoomId at first position
add(0, params.newTopRoomId)
}
?.take(MAX_BREADCRUMBS_ROOMS_NUMBER)
?: listOf(params.newTopRoomId)
// Update the DB locally, do not wait for the sync
saveBreadcrumbsTask.execute(SaveBreadcrumbsTask.Params(newBreadcrumbs))
// FIXME It can remove the previous breadcrumbs, if not synced yet
// And update account data
updateUserAccountDataTask.execute(UpdateUserAccountDataTask.BreadcrumbsParams(
breadcrumbsContent = BreadcrumbsContent(newBreadcrumbs)
))
}
}

View File

@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.session.user.accountdata
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.sync.model.accountdata.BreadcrumbsContent
import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData
import im.vector.matrix.android.internal.task.Task
import javax.inject.Inject
@ -38,6 +39,15 @@ internal interface UpdateUserAccountDataTask : Task<UpdateUserAccountDataTask.Pa
return directMessages
}
}
data class BreadcrumbsParams(override val type: String = UserAccountData.TYPE_BREADCRUMBS,
private val breadcrumbsContent: BreadcrumbsContent
) : Params {
override fun getData(): Any {
return breadcrumbsContent
}
}
}
internal class DefaultUpdateUserAccountDataTask @Inject constructor(private val accountDataApi: AccountDataAPI,

View File

@ -33,6 +33,7 @@ import im.vector.riotx.features.home.LoadingFragment
import im.vector.riotx.features.home.createdirect.CreateDirectRoomDirectoryUsersFragment
import im.vector.riotx.features.home.createdirect.CreateDirectRoomKnownUsersFragment
import im.vector.riotx.features.home.group.GroupListFragment
import im.vector.riotx.features.home.room.breadcrumbs.BreadcrumbsFragment
import im.vector.riotx.features.home.room.detail.RoomDetailFragment
import im.vector.riotx.features.home.room.list.RoomListFragment
import im.vector.riotx.features.login.*
@ -249,4 +250,9 @@ interface FragmentModule {
@IntoMap
@FragmentKey(PublicRoomsFragment::class)
fun bindPublicRoomsFragment(fragment: PublicRoomsFragment): Fragment
@Binds
@IntoMap
@FragmentKey(BreadcrumbsFragment::class)
fun bindBreadcrumbsFragment(fragment: BreadcrumbsFragment): Fragment
}

View File

@ -29,6 +29,7 @@ import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupSharedVie
import im.vector.riotx.features.crypto.verification.SasVerificationViewModel
import im.vector.riotx.features.home.HomeSharedActionViewModel
import im.vector.riotx.features.home.createdirect.CreateDirectRoomSharedActionViewModel
import im.vector.riotx.features.home.room.detail.RoomDetailSharedActionViewModel
import im.vector.riotx.features.home.room.detail.timeline.action.MessageSharedActionViewModel
import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel
import im.vector.riotx.features.login.LoginSharedActionViewModel
@ -118,4 +119,9 @@ interface ViewModelModule {
@IntoMap
@ViewModelKey(LoginSharedActionViewModel::class)
fun bindLoginSharedActionViewModel(viewModel: LoginSharedActionViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(RoomDetailSharedActionViewModel::class)
fun bindRoomDetailSharedActionViewModel(viewModel: RoomDetailSharedActionViewModel): ViewModel
}

View File

@ -0,0 +1,31 @@
/*
* 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.riotx.features.home.room.breadcrumbs
import androidx.recyclerview.widget.DefaultItemAnimator
private const val ANIM_DURATION_IN_MILLIS = 200L
class BreadcrumbsAnimator : DefaultItemAnimator() {
init {
addDuration = ANIM_DURATION_IN_MILLIS
removeDuration = ANIM_DURATION_IN_MILLIS
moveDuration = ANIM_DURATION_IN_MILLIS
changeDuration = ANIM_DURATION_IN_MILLIS
}
}

View File

@ -0,0 +1,74 @@
/*
* 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.riotx.features.home.room.breadcrumbs
import android.view.View
import com.airbnb.epoxy.EpoxyController
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer
import javax.inject.Inject
class BreadcrumbsController @Inject constructor(
private val avatarRenderer: AvatarRenderer
) : EpoxyController() {
var listener: Listener? = null
private var viewState: BreadcrumbsViewState? = null
init {
// We are requesting a model build directly as the first build of epoxy is on the main thread.
// It avoids to build the whole list of breadcrumbs on the main thread.
requestModelBuild()
}
fun update(viewState: BreadcrumbsViewState) {
this.viewState = viewState
requestModelBuild()
}
override fun buildModels() {
val safeViewState = viewState ?: return
// An empty breadcrumbs list can only be temporary because when entering in a room,
// this one is added to the breadcrumbs
safeViewState.asyncBreadcrumbs.invoke()
?.forEach {
breadcrumbsItem {
id(it.roomId)
avatarRenderer(avatarRenderer)
roomId(it.roomId)
roomName(it.displayName)
avatarUrl(it.avatarUrl)
unreadNotificationCount(it.notificationCount)
showHighlighted(it.highlightCount > 0)
hasUnreadMessage(it.hasUnreadMessages)
hasDraft(it.userDrafts.isNotEmpty())
itemClickListener(
DebouncedClickListener(View.OnClickListener { _ ->
listener?.onBreadcrumbClicked(it.roomId)
})
)
}
}
}
interface Listener {
fun onBreadcrumbClicked(roomId: String)
}
}

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.riotx.features.home.room.breadcrumbs
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import com.airbnb.mvrx.fragmentViewModel
import im.vector.riotx.R
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.features.home.room.detail.RoomDetailSharedAction
import im.vector.riotx.features.home.room.detail.RoomDetailSharedActionViewModel
import kotlinx.android.synthetic.main.fragment_breadcrumbs.*
import javax.inject.Inject
class BreadcrumbsFragment @Inject constructor(
private val breadcrumbsController: BreadcrumbsController,
val breadcrumbsViewModelFactory: BreadcrumbsViewModel.Factory
) : VectorBaseFragment(), BreadcrumbsController.Listener {
private lateinit var sharedActionViewModel: RoomDetailSharedActionViewModel
private val breadcrumbsViewModel: BreadcrumbsViewModel by fragmentViewModel()
override fun getLayoutResId() = R.layout.fragment_breadcrumbs
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
sharedActionViewModel = activityViewModelProvider.get(RoomDetailSharedActionViewModel::class.java)
breadcrumbsViewModel.subscribe { renderState(it) }
}
override fun onDestroyView() {
super.onDestroyView()
breadcrumbsRecyclerView.adapter = null
}
private fun setupRecyclerView() {
val layoutManager = LinearLayoutManager(context)
breadcrumbsRecyclerView.layoutManager = layoutManager
breadcrumbsRecyclerView.itemAnimator = BreadcrumbsAnimator()
breadcrumbsController.listener = this
breadcrumbsRecyclerView.setController(breadcrumbsController)
}
private fun renderState(state: BreadcrumbsViewState) {
breadcrumbsController.update(state)
}
// BreadcrumbsController.Listener **************************************************************
override fun onBreadcrumbClicked(roomId: String) {
sharedActionViewModel.post(RoomDetailSharedAction.SwitchToRoom(roomId))
}
}

View File

@ -0,0 +1,60 @@
/*
* 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.riotx.features.home.room.breadcrumbs
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
@EpoxyModelClass(layout = R.layout.item_breadcrumbs)
abstract class BreadcrumbsItem : VectorEpoxyModel<BreadcrumbsItem.Holder>() {
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute lateinit var roomId: String
@EpoxyAttribute lateinit var roomName: CharSequence
@EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var unreadNotificationCount: Int = 0
@EpoxyAttribute var showHighlighted: Boolean = false
@EpoxyAttribute var hasUnreadMessage: Boolean = false
@EpoxyAttribute var hasDraft: Boolean = false
@EpoxyAttribute var itemClickListener: View.OnClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.rootView.setOnClickListener(itemClickListener)
holder.unreadIndentIndicator.isVisible = hasUnreadMessage
avatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
holder.draftIndentIndicator.isVisible = hasDraft
}
class Holder : VectorEpoxyHolder() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.breadcrumbsUnreadCounterBadgeView)
val unreadIndentIndicator by bind<View>(R.id.breadcrumbsUnreadIndicator)
val draftIndentIndicator by bind<View>(R.id.breadcrumbsDraftBadge)
val avatarImageView by bind<ImageView>(R.id.breadcrumbsImageView)
val rootView by bind<ViewGroup>(R.id.breadcrumbsRoot)
}
}

View File

@ -0,0 +1,66 @@
/*
* 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.riotx.features.home.room.breadcrumbs
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.rx.rx
import im.vector.riotx.core.platform.EmptyAction
import im.vector.riotx.core.platform.VectorViewModel
import io.reactivex.schedulers.Schedulers
class BreadcrumbsViewModel @AssistedInject constructor(@Assisted initialState: BreadcrumbsViewState,
private val session: Session)
: VectorViewModel<BreadcrumbsViewState, EmptyAction>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: BreadcrumbsViewState): BreadcrumbsViewModel
}
companion object : MvRxViewModelFactory<BreadcrumbsViewModel, BreadcrumbsViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: BreadcrumbsViewState): BreadcrumbsViewModel? {
val fragment: BreadcrumbsFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.breadcrumbsViewModelFactory.create(state)
}
}
init {
observeBreadcrumbs()
}
override fun handle(action: EmptyAction) {
// No op
}
// PRIVATE METHODS *****************************************************************************
private fun observeBreadcrumbs() {
session.rx()
.liveBreadcrumbs()
.observeOn(Schedulers.computation())
.execute { asyncBreadcrumbs ->
copy(asyncBreadcrumbs = asyncBreadcrumbs)
}
}
}

View File

@ -0,0 +1,26 @@
/*
* 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.riotx.features.home.room.breadcrumbs
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.room.model.RoomSummary
data class BreadcrumbsViewState(
val asyncBreadcrumbs: Async<List<RoomSummary>> = Uninitialized
) : MvRxState

View File

@ -20,17 +20,25 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.widget.Toolbar
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import im.vector.riotx.R
import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.replaceFragment
import im.vector.riotx.core.platform.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.home.room.breadcrumbs.BreadcrumbsFragment
import kotlinx.android.synthetic.main.activity_room_detail.*
import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {
override fun getLayoutRes(): Int {
return R.layout.activity_room_detail
}
override fun getLayoutRes() = R.layout.activity_room_detail
private lateinit var sharedActionViewModel: RoomDetailSharedActionViewModel
// Simple filter
private var currentRoomId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -38,14 +46,57 @@ class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {
if (isFirstCreation()) {
val roomDetailArgs: RoomDetailArgs = intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS)
?: return
currentRoomId = roomDetailArgs.roomId
replaceFragment(R.id.roomDetailContainer, RoomDetailFragment::class.java, roomDetailArgs)
replaceFragment(R.id.roomDetailDrawerContainer, BreadcrumbsFragment::class.java)
}
sharedActionViewModel = viewModelProvider.get(RoomDetailSharedActionViewModel::class.java)
sharedActionViewModel
.observe()
.subscribe { sharedAction ->
when (sharedAction) {
is RoomDetailSharedAction.SwitchToRoom -> switchToRoom(sharedAction)
}
}
.disposeOnDestroy()
drawerLayout.addDrawerListener(drawerListener)
}
private fun switchToRoom(switchToRoom: RoomDetailSharedAction.SwitchToRoom) {
drawerLayout.closeDrawer(GravityCompat.START)
// Do not replace the Fragment if it's the same roomId
if (currentRoomId != switchToRoom.roomId) {
currentRoomId = switchToRoom.roomId
replaceFragment(R.id.roomDetailContainer, RoomDetailFragment::class.java, RoomDetailArgs(switchToRoom.roomId))
}
}
override fun onDestroy() {
drawerLayout.removeDrawerListener(drawerListener)
super.onDestroy()
}
override fun configure(toolbar: Toolbar) {
configureToolbar(toolbar)
}
private val drawerListener = object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerStateChanged(newState: Int) {
hideKeyboard()
}
}
override fun onBackPressed() {
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
drawerLayout.closeDrawer(GravityCompat.START)
} else {
super.onBackPressed()
}
}
companion object {
private const val EXTRA_ROOM_DETAIL_ARGS = "EXTRA_ROOM_DETAIL_ARGS"

View File

@ -0,0 +1,26 @@
/*
* 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.riotx.features.home.room.detail
import im.vector.riotx.core.platform.VectorSharedAction
/**
* Supported navigation actions for [RoomDetailActivity]
*/
sealed class RoomDetailSharedAction : VectorSharedAction {
data class SwitchToRoom(val roomId: String) : RoomDetailSharedAction()
}

View File

@ -0,0 +1,24 @@
/*
* 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.riotx.features.home.room.detail
import im.vector.riotx.core.platform.VectorSharedActionViewModel
import javax.inject.Inject
/**
* Activity shared view model
*/
class RoomDetailSharedActionViewModel @Inject constructor() : VectorSharedActionViewModel<RoomDetailSharedAction>()

View File

@ -143,6 +143,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
timeline.addListener(this)
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
// Inform the SDK that the room is displayed
session.onRoomDisplayed(initialState.roomId)
}
override fun handle(action: RoomDetailAction) {

View File

@ -42,7 +42,7 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
init {
// We are requesting a model build directly as the first build of epoxy is on the main thread.
// It avoids to build the the whole list of rooms on the main thread.
// It avoids to build the whole list of rooms on the main thread.
requestModelBuild()
}

View File

@ -17,6 +17,7 @@
android:layout_height="match_parent" />
<include layout="@layout/merge_overlay_waiting_view" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,14 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
tools:openDrawer="start">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/vector_coordinator_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/roomDetailContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<include layout="@layout/merge_overlay_waiting_view" />
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/roomDetailContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:id="@+id/roomDetailDrawerContainer"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start" />
<include layout="@layout/merge_overlay_waiting_view" />
</FrameLayout>
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<com.airbnb.epoxy.EpoxyRecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/breadcrumbsRecyclerView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?riotx_background"
tools:listitem="@layout/item_breadcrumbs" />

View File

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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/breadcrumbsRoot"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?riotx_background"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground">
<View
android:id="@+id/breadcrumbsUnreadIndicator"
android:layout_width="4dp"
android:layout_height="0dp"
android:background="?attr/colorAccent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<ImageView
android:id="@+id/breadcrumbsImageView"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginStart="12dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
<im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
android:id="@+id/breadcrumbsUnreadCounterBadgeView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:minWidth="16dp"
android:minHeight="16dp"
android:textColor="@android:color/white"
android:textSize="10sp"
android:visibility="gone"
app:layout_constraintCircle="@+id/breadcrumbsImageView"
app:layout_constraintCircleAngle="45"
app:layout_constraintCircleRadius="28dp"
tools:background="@drawable/bg_unread_highlight"
tools:ignore="MissingConstraints"
tools:text="24"
tools:visibility="visible" />
<ImageView
android:id="@+id/breadcrumbsDraftBadge"
android:layout_width="20dp"
android:layout_height="20dp"
android:background="@drawable/circle"
android:padding="3dp"
android:src="@drawable/ic_edit"
android:visibility="gone"
app:layout_constraintCircle="@+id/breadcrumbsImageView"
app:layout_constraintCircleAngle="135"
app:layout_constraintCircleRadius="28dp"
tools:ignore="MissingConstraints"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>