Skip to content
Snippets Groups Projects
Commit 6512877b authored by Kamal Bramwell's avatar Kamal Bramwell
Browse files

Merge branch 'FE-937_requests_search_results' into 'FE-904_search_2_feature'

FE-937: Show matching requests in search results

See merge request elixxir/client-android!69
parents a72d06d8 dbb9e59e
Branches
Tags
4 merge requests!84Version 2.92 build 629,!77v2.9 b627,!76FE-904: search 2 feature,!69FE-937: Show matching requests in search results
......@@ -43,15 +43,6 @@ interface ContactsDao {
@Query("SELECT * FROM Contacts WHERE status = :status")
fun getAllContactsWithStatusLive(status: Int): LiveData<List<ContactData>>
@Query("SELECT * FROM Contacts WHERE username LIKE :username ORDER BY username")
fun queryAllContactsUsername(username: String): Single<List<ContactData>>
@Query("SELECT * FROM Contacts WHERE email LIKE :email ORDER BY email")
fun queryAllContactsEmail(email: String): Single<List<ContactData>>
@Query("SELECT * FROM Contacts WHERE phone LIKE :phone ORDER BY phone")
fun queryAllContactsPhone(phone: String): Single<List<ContactData>>
@Query("SELECT * FROM Contacts WHERE username = :username LIMIT 1")
fun queryContactByUsername(username: String): Maybe<ContactData>
......
......@@ -406,15 +406,6 @@ class DaoRepository @Inject constructor(
}
}
fun connectionsUsernameSearch(username: String): Single<List<ContactData>> =
contactsDao.queryAllContactsUsername(username)
fun connectionsEmailSearch(email: String): Single<List<ContactData>> =
contactsDao.queryAllContactsEmail(email)
fun connectionsPhoneSearch(phone: String): Single<List<ContactData>> =
contactsDao.queryAllContactsPhone(phone)
companion object {
@Volatile
private var instance: DaoRepository? = null
......
......@@ -21,7 +21,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail {
abstract val subtitle: String?
abstract val details: String?
val actionLabel: String? =
open val actionLabel: String? =
when (request.requestStatus) {
VERIFYING -> appContext().getString(R.string.request_item_action_verifying)
VERIFICATION_FAIL -> appContext().getString(R.string.request_item_action_failed_verification)
......@@ -30,7 +30,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail {
else -> null
}
val actionIcon: Int? =
open val actionIcon: Int? =
when (request.requestStatus) {
VERIFICATION_FAIL -> R.drawable.ic_info_outline_24dp
SEND_FAIL, SENT -> R.drawable.ic_retry
......@@ -38,7 +38,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail {
else -> null
}
val actionIconColor: Int? =
open val actionIconColor: Int? =
when (request.requestStatus) {
VERIFICATION_FAIL -> R.color.accent_danger
SEND_FAIL, SENT-> R.color.brand_default
......@@ -47,7 +47,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail {
}
@IdRes
val actionTextStyle: Int? =
open val actionTextStyle: Int? =
when (request.requestStatus) {
VERIFYING -> R.style.request_item_verifying
VERIFICATION_FAIL -> R.style.request_item_error
......@@ -76,6 +76,27 @@ private fun ContactRequest.getContactInfo(): String? =
}
}
data class ContactRequestSearchResultItem(
val contactRequest: ContactRequest,
val photo: Bitmap? = null,
val statusText: String = "Request pending",
val statusTextColor: Int = R.color.neutral_weak,
val actionVisible: Boolean = true
) : RequestItem(contactRequest) {
override val subtitle: String = statusText
override val details: String? = null
override val itemPhoto: Bitmap? = photo
override val itemInitials: String = contactRequest.model.initials
override val itemIconRes: Int? = null
// Request search results always have the "SENT" UI even if it's failed.
// Instead, the failed status is described in the statusText property.
override val actionLabel: String = if (actionVisible) appContext().getString(R.string.request_item_action_retry) else ""
override val actionIcon: Int = R.drawable.ic_retry
override val actionIconColor: Int = R.color.brand_default
override val actionTextStyle: Int = R.style.request_item_retry
}
data class GroupInviteItem(
val invite: GroupInvitation,
// val membersList: List<MemberItem>,
......
......@@ -9,6 +9,7 @@ import io.xxlabs.messenger.R
import io.xxlabs.messenger.databinding.ListItemEmptyPlaceholderBinding
import io.xxlabs.messenger.databinding.ListItemHiddenRequestsToggleBinding
import io.xxlabs.messenger.databinding.ListItemRequestBinding
import io.xxlabs.messenger.databinding.ListItemRequestSearchResultBinding
import io.xxlabs.messenger.databinding.ListItemSectionDividerBinding
import timber.log.Timber
import java.io.InvalidObjectException
......@@ -39,6 +40,31 @@ class RequestViewHolder(
}
}
/**
* For Connection Requests being shown as search results on the Search screen.
*/
class RequestSearchResultViewHolder(
private val binding: ListItemRequestSearchResultBinding
) : RequestItemViewHolder(binding.root) {
override fun onBind(ui: RequestItem, listener: RequestItemListener) {
binding.ui = ui as ContactRequestSearchResultItem
binding.listener = listener
listener.markAsSeen(ui)
}
companion object {
fun create(parent: ViewGroup): RequestSearchResultViewHolder {
val binding = ListItemRequestSearchResultBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return RequestSearchResultViewHolder(binding)
}
}
}
class ConnectionViewHolder(
private val binding: ListItemRequestBinding
) : RequestItemViewHolder(binding.root) {
......
......@@ -11,8 +11,9 @@ class RequestsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RequestItemViewHolder {
return when (ViewType.from(viewType)) {
CONNECTION, SEARCH -> ConnectionViewHolder.create(parent)
CONNECTION, UD_SEARCH_RESULT -> ConnectionViewHolder.create(parent)
REQUEST, INVITE -> RequestViewHolder.create(parent)
REQUEST_SEARCH_RESULT -> RequestSearchResultViewHolder.create(parent)
PLACEHOLDER -> Placeholder.create(parent)
SWITCH -> HiddenRequestToggle.create(parent)
DIVIDER -> ConnectionsSectionDivider.create(parent)
......@@ -35,8 +36,9 @@ class RequestsAdapter(
is EmptyPlaceholderItem -> PLACEHOLDER.value
is HiddenRequestToggleItem -> SWITCH.value
is AcceptedConnectionItem -> CONNECTION.value
is SearchResultItem -> SEARCH.value
is SearchResultItem -> UD_SEARCH_RESULT.value
is ConnectionsDividerItem -> DIVIDER.value
is ContactRequestSearchResultItem -> REQUEST_SEARCH_RESULT.value
else -> OTHER.value
}
status + model
......@@ -49,9 +51,10 @@ class RequestsAdapter(
PLACEHOLDER(300),
SWITCH(400),
CONNECTION(500),
SEARCH(600),
UD_SEARCH_RESULT(600),
DIVIDER(700),
OTHER(800);
REQUEST_SEARCH_RESULT(800),
OTHER(900);
companion object {
fun from(value: Int): ViewType {
......
package io.xxlabs.messenger.search
import android.graphics.Bitmap
import android.text.*
import android.text.Editable
import android.text.Spannable
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.lifecycle.*
import bindings.Fact
import io.xxlabs.messenger.R
import io.xxlabs.messenger.bindings.wrapper.contact.ContactWrapperBase
import io.xxlabs.messenger.data.data.Country
import io.xxlabs.messenger.data.datatype.ContactRequestState
import io.xxlabs.messenger.data.datatype.FactType
import io.xxlabs.messenger.data.datatype.RequestStatus
import io.xxlabs.messenger.data.room.model.ContactData
......@@ -25,7 +30,8 @@ import io.xxlabs.messenger.ui.dialog.info.createInfoDialog
import io.xxlabs.messenger.ui.dialog.info.createTwoButtonDialogUi
import io.xxlabs.messenger.ui.main.countrycode.CountrySelectionListener
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
......@@ -291,26 +297,33 @@ class UserSearchViewModel @Inject constructor(
_udSearchUi.value = searchRunningState
viewModelScope.launch {
/* TODO: When the username matches a connections' nickname, search UD too.
Two users can have the same displayName when
one username matches the other nickname.
*/
clearPreviousResults(resultsEmitter)
searchConnections(factQuery).run {
if (isNotEmpty()) {
resultsEmitter.emitResults(this)
val udResult = searchUd(factQuery)
val requestResults = searchRequests(factQuery)
val connectionResults = searchConnections(factQuery)
val remoteResults = udResult?.let {
listOf(udResult) + requestResults
} ?: requestResults
val sortedResults = if (remoteResults.isEmpty()) {
connectionResults.ifEmpty { noResultsFor(factQuery) }
} else {
null
if (connectionResults.isEmpty()) remoteResults
else remoteResults + listOf(ConnectionsDividerItem()) + connectionResults
}
} ?: searchUd(factQuery)?.run {
resultsEmitter.emitResults(listOf(this))
} ?: resultsEmitter.emitResults(noResultsFor(factQuery))
resultsEmitter.emitResults(sortedResults)
}
}
private fun isValidQuery(factQuery: FactQuery): Boolean {
// Prevent users from searching (and possibly requesting) themselves.
return !preferences.userData.contains(factQuery.fact, true)
return with (factQuery.fact) {
this != repo.getStoredUsername()
&& this != repo.getStoredEmail()
&& this != repo.getStoredPhone()
}
}
private fun noResultsFor(factQuery: FactQuery): List<RequestItem> =
......@@ -330,59 +343,109 @@ class UserSearchViewModel @Inject constructor(
_udSearchUi.value = searchCompleteState
}
private suspend fun searchConnections(factQuery: FactQuery): List<RequestItem> {
return when (factQuery.type) {
private suspend fun savedUsers(): List<ContactData> =
daoRepo.getAllContacts().value()
private fun ContactData.isConnection(): Boolean =
RequestStatus.from(status) == RequestStatus.ACCEPTED
private fun ContactData.isRequest(): Boolean = !isConnection()
private suspend fun searchConnections(factQuery: FactQuery): List<RequestItem> =
withContext(Dispatchers.IO) {
when (factQuery.type) {
FactType.USERNAME -> {
daoRepo.connectionsUsernameSearch(factQuery.fact)
.value()
.asAcceptedConnections()
savedUsers().filter {
it.isConnection() && it.displayName.contains(factQuery.fact)
}.asConnectionsSearchResult()
}
FactType.EMAIL -> {
daoRepo.connectionsEmailSearch(factQuery.fact)
.value()
.asAcceptedConnections()
savedUsers().filter {
it.isConnection() && it.email.contains(factQuery.fact)
}.asConnectionsSearchResult()
}
FactType.PHONE -> {
daoRepo.connectionsPhoneSearch(factQuery.fact)
.value()
.asAcceptedConnections()
savedUsers().filter {
it.isConnection() && it.phone.contains(factQuery.fact)
}.asConnectionsSearchResult()
}
else -> listOf()
}
}
private suspend fun List<ContactData>.asAcceptedConnections(): List<RequestItem> {
val requests = filter {
it.status != RequestStatus.ACCEPTED.value
}.toSet()
// Separate into a sublist of accepted connections and requests.
val contacts = this - requests
// Wrap the requests as SearchResultItems for UI layer.
val requestItems = requests.map {
SearchResultItem(
ContactRequestData(it),
resolveBitmap(it.photo)
)
}.toMutableList()
private suspend fun searchRequests(factQuery: FactQuery): List<RequestItem> =
withContext(Dispatchers.IO) {
when (factQuery.type) {
FactType.USERNAME -> {
savedUsers().filter {
it.isRequest() && it.displayName.contains(factQuery.fact)
}.asRequestsSearchResult()
}
FactType.EMAIL -> {
savedUsers().filter {
it.isRequest() && it.email.contains(factQuery.fact)
}.asRequestsSearchResult()
}
FactType.PHONE -> {
savedUsers().filter {
it.isRequest() && it.phone.contains(factQuery.fact)
}.asRequestsSearchResult()
}
else -> listOf()
}
}
// Wrap the connections as AcceptedConnectionItems for UI layer.
val contactItems = contacts.map {
private suspend fun List<ContactData>.asConnectionsSearchResult(): List<RequestItem> =
map {
val requestData = ContactRequestData(it)
AcceptedConnectionItem(
requestData,
resolveBitmap(it.photo)
)
}
val localResults = if (contacts.isNotEmpty()) {
requestItems + ConnectionsDividerItem() + contactItems
} else {
requestItems
private suspend fun List<ContactData>.asRequestsSearchResult(): List<RequestItem> =
map {
ContactRequestSearchResultItem(
contactRequest = ContactRequestData(it),
photo = resolveBitmap(it.photo),
statusText = it.statusText(),
statusTextColor = it.statusTextColor(),
actionVisible= it.actionVisible()
)
}
private fun ContactData.statusText(): String {
return when (RequestStatus.from(status)) {
RequestStatus.SENT,
RequestStatus.VERIFIED,
RequestStatus.RESET_SENT -> "Request pending"
RequestStatus.SEND_FAIL,
RequestStatus.CONFIRM_FAIL,
RequestStatus.VERIFICATION_FAIL,
RequestStatus.RESET_FAIL -> "Request failed"
else -> ""
}
}
private fun ContactData.statusTextColor(): Int {
return when (RequestStatus.from(status)) {
RequestStatus.SEND_FAIL,
RequestStatus.CONFIRM_FAIL,
RequestStatus.VERIFICATION_FAIL,
RequestStatus.RESET_FAIL -> R.color.accent_danger
else -> R.color.neutral_weak
}
}
private fun ContactData.actionVisible(): Boolean {
return when (RequestStatus.from(status)) {
RequestStatus.VERIFIED -> false
else -> true
}
Timber.d("RequestItems: ${requestItems.size} entries")
Timber.d("ContactItems: ${contactItems.size} entries")
Timber.d("LocalResults: ${localResults.size} entries")
return localResults
}
private suspend fun resolveBitmap(data: ByteArray?): Bitmap? = withContext(Dispatchers.IO) {
......@@ -394,7 +457,9 @@ class UserSearchViewModel @Inject constructor(
val udResult = repo.searchUd(factQuery.fact, factQuery.type).value()
udResult.second?.let { // Error message
if (it.isNotEmpty()) {
if (!it.contains("no results found", true)) {
showToast(it)
}
noResultPlaceholder(factQuery)
} else { // Search result
udResult.first?.asSearchResult() ?: noResultPlaceholder(factQuery)
......
<?xml version="1.0" encoding="utf-8"?>
<layout
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">
<data>
<import type="io.xxlabs.messenger.ui.main.chat.DrawablePosition"/>
<variable
name="ui"
type="io.xxlabs.messenger.requests.ui.list.adapter.ContactRequestSearchResultItem" />
<variable
name="listener"
type="io.xxlabs.messenger.requests.ui.list.adapter.RequestItemListener" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/requestItemLayout"
android:padding="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="@{() -> listener.onItemClicked(ui)}" >
<include
android:id="@+id/requestProfilePhoto"
layout="@layout/component_item_thumbnail"
android:layout_width="@dimen/spacing_42"
android:layout_height="@dimen/spacing_42"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:ui="@{ui}" />
<TextView
android:id="@+id/requestTitle"
style="@style/request_item_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@{ui.title}"
app:layout_constraintBottom_toTopOf="@+id/requestSubtitle"
app:layout_constraintEnd_toStartOf="@id/requestAction"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@id/requestProfilePhoto"
app:layout_constraintTop_toTopOf="@id/requestProfilePhoto"
tools:text="bartender007" />
<TextView
android:id="@+id/requestSubtitle"
style="@style/request_item_subheader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{ui.subtitle}"
android:visibility="@{ui.subtitle}"
android:textColor="@{ui.statusTextColor}"
app:layout_constraintEnd_toStartOf="@id/requestAction"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@id/requestProfilePhoto"
app:layout_constraintTop_toBottomOf="@id/requestTitle"
tools:text="Request pending"
tools:textColor="@color/neutral_weak"/>
<io.xxlabs.messenger.support.view.SingleClickTextView
android:id="@+id/requestAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{ui.actionLabel}"
android:padding="12dp"
android:layout_marginHorizontal="0dp"
android:drawablePadding="4dp"
android:onClick="@{() -> listener.onActionClicked(ui)}"
android:visibility="@{ui.actionLabel}"
android:singleLine="false"
android:gravity="center_vertical|end"
app:actionIcon="@{ui.actionIcon}"
app:iconColor="@{ui.actionIconColor}"
app:iconPosition="@{DrawablePosition.START}"
app:customStyle="@{ui.actionTextStyle}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:drawableLeft="@drawable/ic_retry"
tools:text="Resend"
style="@style/request_item_subheader"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment