diff --git a/presentation/src/main/java/common/util/filter/ContactFilter.kt b/data/src/main/java/filter/ContactFilter.kt
similarity index 97%
rename from presentation/src/main/java/common/util/filter/ContactFilter.kt
rename to data/src/main/java/filter/ContactFilter.kt
index f108ad6e..a6cf02e2 100644
--- a/presentation/src/main/java/common/util/filter/ContactFilter.kt
+++ b/data/src/main/java/filter/ContactFilter.kt
@@ -16,7 +16,7 @@
* You should have received a copy of the GNU General Public License
* along with QKSMS. If not, see .
*/
-package common.util.filter
+package filter
import model.Contact
import util.extensions.removeAccents
diff --git a/presentation/src/main/java/common/util/filter/ConversationFilter.kt b/data/src/main/java/filter/ConversationFilter.kt
similarity index 97%
rename from presentation/src/main/java/common/util/filter/ConversationFilter.kt
rename to data/src/main/java/filter/ConversationFilter.kt
index fcc0bdbf..6fdc8532 100644
--- a/presentation/src/main/java/common/util/filter/ConversationFilter.kt
+++ b/data/src/main/java/filter/ConversationFilter.kt
@@ -16,7 +16,7 @@
* You should have received a copy of the GNU General Public License
* along with QKSMS. If not, see .
*/
-package common.util.filter
+package filter
import model.Conversation
import javax.inject.Inject
diff --git a/presentation/src/main/java/common/util/filter/Filter.kt b/data/src/main/java/filter/Filter.kt
similarity index 96%
rename from presentation/src/main/java/common/util/filter/Filter.kt
rename to data/src/main/java/filter/Filter.kt
index 5cfe9f47..3188a002 100644
--- a/presentation/src/main/java/common/util/filter/Filter.kt
+++ b/data/src/main/java/filter/Filter.kt
@@ -16,7 +16,7 @@
* You should have received a copy of the GNU General Public License
* along with QKSMS. If not, see .
*/
-package common.util.filter
+package filter
abstract class Filter {
diff --git a/presentation/src/main/java/common/util/filter/PhoneNumberFilter.kt b/data/src/main/java/filter/PhoneNumberFilter.kt
similarity index 97%
rename from presentation/src/main/java/common/util/filter/PhoneNumberFilter.kt
rename to data/src/main/java/filter/PhoneNumberFilter.kt
index 631a3869..891803b5 100644
--- a/presentation/src/main/java/common/util/filter/PhoneNumberFilter.kt
+++ b/data/src/main/java/filter/PhoneNumberFilter.kt
@@ -16,7 +16,7 @@
* You should have received a copy of the GNU General Public License
* along with QKSMS. If not, see .
*/
-package common.util.filter
+package filter
import android.telephony.PhoneNumberUtils
import javax.inject.Inject
diff --git a/presentation/src/main/java/common/util/filter/RecipientFilter.kt b/data/src/main/java/filter/RecipientFilter.kt
similarity index 97%
rename from presentation/src/main/java/common/util/filter/RecipientFilter.kt
rename to data/src/main/java/filter/RecipientFilter.kt
index cbd27664..f38a7e78 100644
--- a/presentation/src/main/java/common/util/filter/RecipientFilter.kt
+++ b/data/src/main/java/filter/RecipientFilter.kt
@@ -16,7 +16,7 @@
* You should have received a copy of the GNU General Public License
* along with QKSMS. If not, see .
*/
-package common.util.filter
+package filter
import model.Recipient
import javax.inject.Inject
diff --git a/data/src/main/java/repository/MessageRepositoryImpl.kt b/data/src/main/java/repository/MessageRepositoryImpl.kt
index 6c671965..93ae7e23 100644
--- a/data/src/main/java/repository/MessageRepositoryImpl.kt
+++ b/data/src/main/java/repository/MessageRepositoryImpl.kt
@@ -32,10 +32,11 @@ import android.telephony.PhoneNumberUtils
import android.telephony.SmsManager
import com.klinker.android.send_message.BroadcastUtils
import com.klinker.android.send_message.StripAccents
-import io.reactivex.Flowable
+import filter.ConversationFilter
import io.reactivex.Maybe
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
+import io.realm.Case
import io.realm.Realm
import io.realm.RealmResults
import io.realm.Sort
@@ -46,6 +47,7 @@ import model.Contact
import model.Conversation
import model.Message
import model.MmsPart
+import model.SearchResult
import util.MessageUtils
import util.Preferences
import util.extensions.anyOf
@@ -59,13 +61,13 @@ import javax.inject.Singleton
class MessageRepositoryImpl @Inject constructor(
private val context: Context,
private val messageIds: KeyManager,
+ private val conversationFilter: ConversationFilter,
private val cursorToConversation: CursorToConversation,
private val cursorToRecipient: CursorToRecipient,
private val prefs: Preferences) : MessageRepository {
- override fun getConversations(archived: Boolean): Flowable> {
- val realm = Realm.getDefaultInstance()
- return realm
+ override fun getConversations(archived: Boolean): RealmResults {
+ return Realm.getDefaultInstance()
.where(Conversation::class.java)
.notEqualTo("id", 0L)
.greaterThan("count", 0)
@@ -74,10 +76,6 @@ class MessageRepositoryImpl @Inject constructor(
.isNotEmpty("recipients")
.sort("date", Sort.DESCENDING)
.findAllAsync()
- .asFlowable()
- .filter { it.isLoaded }
- .filter { it.isValid }
- .map { conversations -> realm.copyFromRealm(conversations) }
}
override fun getConversationsSnapshot(): List {
@@ -92,6 +90,25 @@ class MessageRepositoryImpl @Inject constructor(
.findAll())
}
+ override fun searchConversations(query: String): List {
+ val conversations = getConversationsSnapshot()
+
+ val messagesByConversation = Realm.getDefaultInstance()
+ .where(Message::class.java)
+ .contains("body", query, Case.INSENSITIVE)
+ .findAll()
+ .groupBy { message -> message.threadId }
+ .filter { (threadId, _) -> conversations.firstOrNull { it.id == threadId } != null }
+ .map { (threadId, messages) -> Pair(conversations.first { it.id == threadId }, messages.size) }
+ .map { (conversation, messages) -> SearchResult(query, conversation, messages) }
+ .sortedByDescending { result -> result.messages }
+
+ return conversations
+ .filter { conversation -> conversationFilter.filter(conversation, query) }
+ .map { conversation -> SearchResult(query, conversation, 0) }
+ .plus(messagesByConversation)
+ }
+
override fun getBlockedConversations(): RealmResults {
return Realm.getDefaultInstance()
.where(Conversation::class.java)
@@ -166,10 +183,11 @@ class MessageRepositoryImpl @Inject constructor(
}
}
- override fun getMessages(threadId: Long): RealmResults {
+ override fun getMessages(threadId: Long, query: String): RealmResults {
return Realm.getDefaultInstance()
.where(Message::class.java)
.equalTo("threadId", threadId)
+ .let { if (query.isEmpty()) it else it.contains("body", query, Case.INSENSITIVE) }
.sort("date")
.findAllAsync()
}
diff --git a/domain/src/main/java/model/SearchResult.kt b/domain/src/main/java/model/SearchResult.kt
new file mode 100644
index 00000000..a1cbcd76
--- /dev/null
+++ b/domain/src/main/java/model/SearchResult.kt
@@ -0,0 +1,3 @@
+package model
+
+data class SearchResult(val query: String, val conversation: Conversation, val messages: Int)
\ No newline at end of file
diff --git a/domain/src/main/java/repository/MessageRepository.kt b/domain/src/main/java/repository/MessageRepository.kt
index 2bf2323d..fc313ffc 100644
--- a/domain/src/main/java/repository/MessageRepository.kt
+++ b/domain/src/main/java/repository/MessageRepository.kt
@@ -18,19 +18,21 @@
*/
package repository
-import io.reactivex.Flowable
import io.reactivex.Maybe
import io.realm.RealmResults
import model.Conversation
import model.Message
import model.MmsPart
+import model.SearchResult
interface MessageRepository {
- fun getConversations(archived: Boolean = false): Flowable>
+ fun getConversations(archived: Boolean = false): RealmResults
fun getConversationsSnapshot(): List
+ fun searchConversations(query: String): List
+
fun getBlockedConversations(): RealmResults
fun getConversationAsync(threadId: Long): Conversation
@@ -45,7 +47,7 @@ interface MessageRepository {
fun saveDraft(threadId: Long, draft: String)
- fun getMessages(threadId: Long): RealmResults
+ fun getMessages(threadId: Long, query: String = ""): RealmResults
fun getMessage(id: Long): Message?
diff --git a/presentation/src/main/java/common/Navigator.kt b/presentation/src/main/java/common/Navigator.kt
index ac80a6bd..8707e2df 100644
--- a/presentation/src/main/java/common/Navigator.kt
+++ b/presentation/src/main/java/common/Navigator.kt
@@ -84,9 +84,10 @@ class Navigator @Inject constructor(private val context: Context, private val no
startActivity(intent)
}
- fun showConversation(threadId: Long) {
+ fun showConversation(threadId: Long, query: String? = null) {
val intent = Intent(context, ComposeActivity::class.java)
- intent.putExtra("threadId", threadId)
+ .putExtra("threadId", threadId)
+ .putExtra("query", query)
startActivity(intent)
}
diff --git a/presentation/src/main/java/common/base/FlowableAdapter.kt b/presentation/src/main/java/common/base/FlowableAdapter.kt
index d0574adf..bf312bff 100644
--- a/presentation/src/main/java/common/base/FlowableAdapter.kt
+++ b/presentation/src/main/java/common/base/FlowableAdapter.kt
@@ -20,8 +20,6 @@ package common.base
import android.support.annotation.CallSuper
import android.support.v7.widget.RecyclerView
-import android.view.View
-import common.util.extensions.setVisible
import io.reactivex.Flowable
import io.reactivex.disposables.Disposable
@@ -31,16 +29,6 @@ import io.reactivex.disposables.Disposable
*/
abstract class FlowableAdapter : QkAdapter() {
- /**
- * This view can be set, and the adapter will automatically control the visibility of this view
- * based on the data
- */
- var emptyView: View? = null
- set(value) {
- field = value
- value?.setVisible(false)
- }
-
var flowable: Flowable>? = null
set(value) {
if (field === value) return
@@ -83,7 +71,6 @@ abstract class FlowableAdapter : QkAdapter() {
private fun subscribe() {
disposable = flowable?.subscribe {
data = it
- emptyView?.setVisible(data.isEmpty())
}
}
diff --git a/presentation/src/main/java/common/base/QkAdapter.kt b/presentation/src/main/java/common/base/QkAdapter.kt
index ee9d8db9..5be001a9 100644
--- a/presentation/src/main/java/common/base/QkAdapter.kt
+++ b/presentation/src/main/java/common/base/QkAdapter.kt
@@ -20,6 +20,8 @@ package common.base
import android.support.v7.util.DiffUtil
import android.support.v7.widget.RecyclerView
+import android.view.View
+import common.util.extensions.setVisible
import io.reactivex.subjects.BehaviorSubject
import io.reactivex.subjects.Subject
@@ -36,7 +38,18 @@ abstract class QkAdapter : RecyclerView.Adapter() {
val diff = DiffUtil.calculateDiff(getDiffUtilCallback(field, value))
field = value
diff.dispatchUpdatesTo(this)
- onDatasetChanged()
+
+ emptyView?.setVisible(value.isEmpty())
+ }
+
+ /**
+ * This view can be set, and the adapter will automatically control the visibility of this view
+ * based on the data
+ */
+ var emptyView: View? = null
+ set(value) {
+ field = value
+ value?.setVisible(false)
}
val selectionChanges: Subject> = BehaviorSubject.create()
diff --git a/presentation/src/main/java/common/base/QkRealmAdapter.kt b/presentation/src/main/java/common/base/QkRealmAdapter.kt
index 092af102..65143f31 100644
--- a/presentation/src/main/java/common/base/QkRealmAdapter.kt
+++ b/presentation/src/main/java/common/base/QkRealmAdapter.kt
@@ -56,6 +56,8 @@ abstract class QkRealmAdapter : RealmRecyclerViewAdapter?) {
+ if (getData() === data) return
+
removeListener(getData())
addListener(data)
diff --git a/presentation/src/main/java/common/base/QkThemedActivity.kt b/presentation/src/main/java/common/base/QkThemedActivity.kt
index 509b546c..5ce7f875 100644
--- a/presentation/src/main/java/common/base/QkThemedActivity.kt
+++ b/presentation/src/main/java/common/base/QkThemedActivity.kt
@@ -82,14 +82,14 @@ abstract class QkThemedActivity : QkActivity() {
super.onPostCreate(savedInstanceState)
// Update the colours of the menu items
- Observables.combineLatest(menu, theme, colors.textTertiary, { menu, theme, textTertiary ->
+ Observables.combineLatest(menu, theme, colors.textSecondary, { menu, theme, text ->
(0 until menu.size())
.map { position -> menu.getItem(position) }
.forEach { menuItem ->
menuItem?.icon?.run {
setTint(when (menuItem.itemId) {
- R.id.info -> textTertiary
- else -> theme
+ in getColoredMenuItems() -> theme
+ else -> text
})
menuItem.icon = this
@@ -119,6 +119,10 @@ abstract class QkThemedActivity : QkActivity() {
.subscribe { color -> toolbar?.setBackgroundTint(color) }
}
+ open fun getColoredMenuItems(): List {
+ return listOf()
+ }
+
/**
* This can be overridden in case an activity does not want to use the default themes
*/
diff --git a/presentation/src/main/java/common/util/Colors.kt b/presentation/src/main/java/common/util/Colors.kt
index a18d9175..9e09243d 100644
--- a/presentation/src/main/java/common/util/Colors.kt
+++ b/presentation/src/main/java/common/util/Colors.kt
@@ -204,6 +204,11 @@ class Colors @Inject constructor(private val context: Context, private val prefs
.map { res -> getColor(res) }
.distinctUntilChanged()
+ val highlightColor: Observable = theme
+ .map { theme -> FloatArray(3).apply { Color.colorToHSV(theme, this) } }
+ .map { hsv -> hsv.apply { set(2, 0.75f) } } // 75% value
+ .map { hsv -> Color.HSVToColor(85, hsv) } // 33% alpha
+
val textPrimaryOnTheme = textPrimaryOnThemeForConversation()
val textSecondaryOnTheme = textSecondaryOnThemeForConversation()
diff --git a/presentation/src/main/java/feature/compose/ComposeActivity.kt b/presentation/src/main/java/feature/compose/ComposeActivity.kt
index 8e5be34e..786e50f4 100644
--- a/presentation/src/main/java/feature/compose/ComposeActivity.kt
+++ b/presentation/src/main/java/feature/compose/ComposeActivity.kt
@@ -171,11 +171,15 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
threadId.onNext(state.selectedConversation)
- title = when (state.selectedMessages) {
- 0 -> state.conversationtitle
- else -> getString(R.string.compose_title_selected, state.selectedMessages)
+ title = when {
+ state.selectedMessages > 0 -> getString(R.string.compose_title_selected, state.selectedMessages)
+ state.query.isNotEmpty() -> state.query
+ else -> state.conversationtitle
}
+ toolbarSubtitle.setVisible(state.query.isNotEmpty())
+ toolbarSubtitle.text = getString(R.string.compose_subtitle_results, state.searchSelectionPosition, state.searchResults)
+
toolbarTitle.setVisible(!state.editingMode)
chips.setVisible(state.editingMode)
contacts.setVisible(state.contactsVisible)
@@ -185,11 +189,14 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
if (state.editingMode && chips.adapter == null) chips.adapter = chipsAdapter
if (state.editingMode && contacts.adapter == null) contacts.adapter = contactsAdapter
- toolbar.menu.findItem(R.id.call)?.isVisible = !state.editingMode && state.selectedMessages == 0
- toolbar.menu.findItem(R.id.info)?.isVisible = !state.editingMode && state.selectedMessages == 0
+ toolbar.menu.findItem(R.id.call)?.isVisible = !state.editingMode && state.selectedMessages == 0 && state.query.isEmpty()
+ toolbar.menu.findItem(R.id.info)?.isVisible = !state.editingMode && state.selectedMessages == 0 && state.query.isEmpty()
toolbar.menu.findItem(R.id.copy)?.isVisible = !state.editingMode && state.selectedMessages == 1
toolbar.menu.findItem(R.id.delete)?.isVisible = !state.editingMode && state.selectedMessages > 0
toolbar.menu.findItem(R.id.forward)?.isVisible = !state.editingMode && state.selectedMessages == 1
+ toolbar.menu.findItem(R.id.previous)?.isVisible = state.selectedMessages == 0 && state.query.isNotEmpty()
+ toolbar.menu.findItem(R.id.next)?.isVisible = state.selectedMessages == 0 && state.query.isNotEmpty()
+ toolbar.menu.findItem(R.id.clear)?.isVisible = state.selectedMessages == 0 && state.query.isNotEmpty()
if (chipsAdapter.data.isEmpty() && state.selectedContacts.isNotEmpty()) {
message.showKeyboard()
@@ -198,6 +205,7 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
chipsAdapter.data = state.selectedContacts
contactsAdapter.data = state.contacts
messageAdapter.data = state.messages
+ messageAdapter.highlight = state.searchSelectionId
attachments.setVisible(state.attachments.isNotEmpty())
attachmentAdapter.data = state.attachments
@@ -220,6 +228,13 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
message.setText(draft)
}
+ override fun scrollToMessage(id: Long) {
+ messageAdapter.data?.second
+ ?.indexOfLast { message -> message.id == id }
+ ?.takeIf { position -> position != -1 }
+ ?.let(messageList::scrollToPosition)
+ }
+
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.compose, menu)
return super.onCreateOptionsMenu(menu)
@@ -230,6 +245,10 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
return true
}
+ override fun getColoredMenuItems(): List {
+ return super.getColoredMenuItems() + R.id.call
+ }
+
override fun onBackPressed() {
backPressedIntent.onNext(Unit)
}
diff --git a/presentation/src/main/java/feature/compose/ComposeState.kt b/presentation/src/main/java/feature/compose/ComposeState.kt
index 25fc76ab..5ed38897 100644
--- a/presentation/src/main/java/feature/compose/ComposeState.kt
+++ b/presentation/src/main/java/feature/compose/ComposeState.kt
@@ -31,6 +31,10 @@ data class ComposeState(
val selectedConversation: Long = 0,
val selectedContacts: List = ArrayList(),
val conversationtitle: String = "",
+ val query: String = "",
+ val searchSelectionId: Long = -1,
+ val searchSelectionPosition: Int = 0,
+ val searchResults: Int = 0,
val messages: Pair>? = null,
val selectedMessages: Int = 0,
val attachments: List = ArrayList(),
diff --git a/presentation/src/main/java/feature/compose/ComposeView.kt b/presentation/src/main/java/feature/compose/ComposeView.kt
index e9ef292c..fa1b477f 100644
--- a/presentation/src/main/java/feature/compose/ComposeView.kt
+++ b/presentation/src/main/java/feature/compose/ComposeView.kt
@@ -48,6 +48,7 @@ interface ComposeView : QkView {
fun clearSelection()
fun setDraft(draft: String)
+ fun scrollToMessage(id: Long)
val backPressedIntent: Observable
}
\ No newline at end of file
diff --git a/presentation/src/main/java/feature/compose/ComposeViewModel.kt b/presentation/src/main/java/feature/compose/ComposeViewModel.kt
index f2426fc7..c53958aa 100644
--- a/presentation/src/main/java/feature/compose/ComposeViewModel.kt
+++ b/presentation/src/main/java/feature/compose/ComposeViewModel.kt
@@ -33,7 +33,7 @@ import common.Navigator
import common.base.QkViewModel
import common.util.ClipboardUtils
import common.util.extensions.makeToast
-import common.util.filter.ContactFilter
+import filter.ContactFilter
import interactor.CancelDelayedMessage
import interactor.ContactSync
import interactor.DeleteMessages
@@ -64,7 +64,6 @@ import java.net.URLDecoder
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Inject
-import javax.inject.Named
class ComposeViewModel @Inject constructor(
private val intent: Intent,
@@ -79,19 +78,19 @@ class ComposeViewModel @Inject constructor(
private val retrySending: RetrySending,
private val markRead: MarkRead,
private val deleteMessages: DeleteMessages
-) : QkViewModel(ComposeState()) {
+) : QkViewModel(ComposeState(query = intent.extras?.getString("query") ?: "")) {
- private var sharedText: String = ""
+ private var sharedText: String = intent.extras?.getString(Intent.EXTRA_TEXT) ?: ""
private val attachments: Subject> = BehaviorSubject.createDefault(ArrayList())
private val contacts: Observable> by lazy { contactsRepo.getUnmanagedContacts().toObservable() }
private val contactsReducer: Subject<(List) -> List> = PublishSubject.create()
private val selectedContacts: Observable>
+ private val searchResults: Subject> = BehaviorSubject.create()
+ private val searchSelection: Subject = BehaviorSubject.createDefault(-1)
private val conversation: Subject = BehaviorSubject.create()
private val messages: Subject> = BehaviorSubject.create()
init {
- sharedText = intent.extras?.getString(Intent.EXTRA_TEXT) ?: ""
-
// If there are any image attachments, we'll set those as the initial attachments for the
// conversation
val sharedImages = mutableListOf()
@@ -176,6 +175,25 @@ class ComposeViewModel @Inject constructor(
disposables += attachments
.subscribe { attachments -> newState { it.copy(attachments = attachments) } }
+ disposables += conversation
+ .map { conversation -> conversation.id }
+ .distinctUntilChanged()
+ .withLatestFrom(state) { id, state -> messageRepo.getMessages(id, state.query) }
+ .switchMap { messages -> messages.asObservable() }
+ .takeUntil(state.map { it.query }.filter { it.isEmpty() })
+ .filter { messages -> messages.isLoaded }
+ .filter { messages -> messages.isValid }
+ .subscribe(searchResults::onNext)
+
+ disposables += Observables.combineLatest(searchSelection, searchResults) { selected, messages ->
+ if (selected == -1L) {
+ messages.lastOrNull()?.let { message -> searchSelection.onNext(message.id) }
+ } else {
+ val position = messages.indexOfFirst { it.id == selected } + 1
+ newState { it.copy(searchSelectionPosition = position, searchResults = messages.size) }
+ }
+ }.subscribe()
+
if (threadId == 0L) {
syncContacts.execute(Unit)
}
@@ -314,6 +332,48 @@ class ComposeViewModel @Inject constructor(
.autoDisposable(view.scope())
.subscribe { view.clearSelection() }
+
+ // Show the previous search result
+ view.optionsItemIntent
+ .filter { it == R.id.previous }
+ .withLatestFrom(searchSelection, searchResults) { _, selection, messages ->
+ val currentPosition = messages.indexOfFirst { it.id == selection }
+ if (currentPosition <= 0L) messages.lastOrNull()?.id ?: -1
+ else messages.getOrNull(currentPosition - 1)?.id ?: -1
+ }
+ .filter { id -> id != -1L }
+ .autoDisposable(view.scope())
+ .subscribe(searchSelection)
+
+
+ // Show the next search result
+ view.optionsItemIntent
+ .filter { it == R.id.next }
+ .withLatestFrom(searchSelection, searchResults) { _, selection, messages ->
+ val currentPosition = messages.indexOfFirst { it.id == selection }
+ if (currentPosition >= messages.size - 1) messages.firstOrNull()?.id ?: -1
+ else messages.getOrNull(currentPosition + 1)?.id ?: -1
+ }
+ .filter { id -> id != -1L }
+ .autoDisposable(view.scope())
+ .subscribe(searchSelection)
+
+
+ // Clear the search
+ view.optionsItemIntent
+ .filter { it == R.id.clear }
+ .autoDisposable(view.scope())
+ .subscribe { newState { it.copy(query = "", searchSelectionId = -1) } }
+
+
+ // Scroll to search position
+ searchSelection
+ .filter { id -> id != -1L }
+ .doOnNext { id -> newState { it.copy(searchSelectionId = id) } }
+ .autoDisposable(view.scope())
+ .subscribe(view::scrollToMessage)
+
+
// Retry sending
view.messageClickIntent
.filter { message -> message.isFailedMessage() }
diff --git a/presentation/src/main/java/feature/compose/MessagesAdapter.kt b/presentation/src/main/java/feature/compose/MessagesAdapter.kt
index 470980bf..83def776 100644
--- a/presentation/src/main/java/feature/compose/MessagesAdapter.kt
+++ b/presentation/src/main/java/feature/compose/MessagesAdapter.kt
@@ -103,6 +103,17 @@ class MessagesAdapter @Inject constructor(
private val conversation: Conversation?
get() = data?.first?.takeIf { it.isValid }
+ /**
+ * Mark this message as highlighted
+ */
+ var highlight: Long = -1L
+ set(value) {
+ if (field == value) return
+
+ field = value
+ notifyDataSetChanged()
+ }
+
private val layoutInflater = LayoutInflater.from(context)
private val contactCache = ContactCache()
private val expanded = HashMap()
@@ -178,7 +189,7 @@ class MessagesAdapter @Inject constructor(
// Update the selected state
- view.isSelected = isSelected(message.id)
+ view.isSelected = isSelected(message.id) || highlight == message.id
// Bind the cancel view
diff --git a/presentation/src/main/java/feature/conversations/ConversationsAdapter.kt b/presentation/src/main/java/feature/conversations/ConversationsAdapter.kt
index cb715b3e..bd68250f 100644
--- a/presentation/src/main/java/feature/conversations/ConversationsAdapter.kt
+++ b/presentation/src/main/java/feature/conversations/ConversationsAdapter.kt
@@ -27,7 +27,7 @@ import com.jakewharton.rxbinding2.view.clicks
import com.jakewharton.rxbinding2.view.longClicks
import com.moez.QKSMS.R
import common.Navigator
-import common.base.FlowableAdapter
+import common.base.QkRealmAdapter
import common.base.QkViewHolder
import common.util.Colors
import common.util.DateFormatter
@@ -42,7 +42,7 @@ class ConversationsAdapter @Inject constructor(
private val colors: Colors,
private val dateFormatter: DateFormatter,
private val navigator: Navigator
-) : FlowableAdapter() {
+) : QkRealmAdapter() {
private val disposables = CompositeDisposable()
@@ -72,7 +72,7 @@ class ConversationsAdapter @Inject constructor(
}
override fun onBindViewHolder(viewHolder: QkViewHolder, position: Int) {
- val conversation = getItem(position)
+ val conversation = getItem(position)!!
val view = viewHolder.itemView
view.clicks().subscribe {
@@ -103,14 +103,10 @@ class ConversationsAdapter @Inject constructor(
}
override fun getItemId(index: Int): Long {
- return getItem(index).id
+ return getItem(index)!!.id
}
override fun getItemViewType(position: Int): Int {
- return if (getItem(position).read) 0 else 1
- }
-
- override fun areItemsTheSame(old: Conversation, new: Conversation): Boolean {
- return old.id == new.id
+ return if (getItem(position)!!.read) 0 else 1
}
}
\ No newline at end of file
diff --git a/presentation/src/main/java/feature/main/MainActivity.kt b/presentation/src/main/java/feature/main/MainActivity.kt
index 1700b7c9..08393899 100644
--- a/presentation/src/main/java/feature/main/MainActivity.kt
+++ b/presentation/src/main/java/feature/main/MainActivity.kt
@@ -64,6 +64,7 @@ class MainActivity : QkThemedActivity(), MainView {
@Inject lateinit var navigator: Navigator
@Inject lateinit var conversationsAdapter: ConversationsAdapter
+ @Inject lateinit var searchAdapter: SearchAdapter
@Inject lateinit var itemTouchCallback: ConversationItemTouchCallback
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory
@@ -202,7 +203,7 @@ class MainActivity : QkThemedActivity(), MainView {
else -> 0
}
- toolbarSearch.setVisible(state.page is Inbox && state.page.selected == 0)
+ toolbarSearch.setVisible(state.page is Inbox && state.page.selected == 0 || state.page is Searching)
toolbarTitle.setVisible(toolbarSearch.visibility != View.VISIBLE)
toolbar.menu.findItem(R.id.archive)?.isVisible = state.page is Inbox && selectedConversations != 0
@@ -219,12 +220,17 @@ class MainActivity : QkThemedActivity(), MainView {
showBackButton(state.page.showClearButton)
title = getString(R.string.main_title_selected, state.page.selected)
if (recyclerView.adapter !== conversationsAdapter) recyclerView.adapter = conversationsAdapter
- conversationsAdapter.flowable = state.page.data
+ conversationsAdapter.updateData(state.page.data)
itemTouchHelper.attachToRecyclerView(recyclerView)
- empty.setText(when (state.page.showClearButton) {
- true -> R.string.inbox_search_empty_text
- false -> R.string.inbox_empty_text
- })
+ empty.setText(R.string.inbox_empty_text)
+ }
+
+ is Searching -> {
+ showBackButton(true)
+ if (recyclerView.adapter !== searchAdapter) recyclerView.adapter = searchAdapter
+ searchAdapter.data = state.page.data ?: listOf()
+ itemTouchHelper.attachToRecyclerView(null)
+ empty.setText(R.string.inbox_search_empty_text)
}
is Archived -> {
@@ -234,7 +240,7 @@ class MainActivity : QkThemedActivity(), MainView {
false -> getString(R.string.title_archived)
}
if (recyclerView.adapter !== conversationsAdapter) recyclerView.adapter = conversationsAdapter
- conversationsAdapter.flowable = state.page.data
+ conversationsAdapter.updateData(state.page.data)
itemTouchHelper.attachToRecyclerView(null)
empty.setText(R.string.archived_empty_text)
}
diff --git a/presentation/src/main/java/feature/main/MainState.kt b/presentation/src/main/java/feature/main/MainState.kt
index 36462be6..e621b09b 100644
--- a/presentation/src/main/java/feature/main/MainState.kt
+++ b/presentation/src/main/java/feature/main/MainState.kt
@@ -18,8 +18,9 @@
*/
package feature.main
-import io.reactivex.Flowable
+import io.realm.RealmResults
import model.Conversation
+import model.SearchResult
import repository.SyncRepository
data class MainState(
@@ -37,13 +38,18 @@ sealed class MainPage
data class Inbox(
val showClearButton: Boolean = false,
- val data: Flowable>? = null,
+ val data: RealmResults? = null,
val selected: Int = 0,
val showArchivedSnackbar: Boolean = false) : MainPage()
+data class Searching(
+ val loading: Boolean = false,
+ val data: List? = null
+) : MainPage()
+
data class Archived(
val showClearButton: Boolean = false,
- val data: Flowable>? = null,
+ val data: RealmResults? = null,
val selected: Int = 0) : MainPage()
data class Scheduled(
diff --git a/presentation/src/main/java/feature/main/MainViewModel.kt b/presentation/src/main/java/feature/main/MainViewModel.kt
index 67ca9494..6f9f01b3 100644
--- a/presentation/src/main/java/feature/main/MainViewModel.kt
+++ b/presentation/src/main/java/feature/main/MainViewModel.kt
@@ -23,7 +23,6 @@ import com.uber.autodispose.android.lifecycle.scope
import com.uber.autodispose.kotlin.autoDisposable
import common.Navigator
import common.base.QkViewModel
-import common.util.filter.ConversationFilter
import interactor.DeleteConversations
import interactor.MarkAllSeen
import interactor.MarkArchived
@@ -32,9 +31,11 @@ import interactor.MarkUnarchived
import interactor.MigratePreferences
import interactor.SyncMessages
import io.reactivex.Observable
+import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxkotlin.Observables
import io.reactivex.rxkotlin.plusAssign
import io.reactivex.rxkotlin.withLatestFrom
+import io.reactivex.schedulers.Schedulers
import io.realm.Realm
import manager.PermissionManager
import manager.RatingManager
@@ -46,7 +47,6 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
class MainViewModel @Inject constructor(
- private val conversationFilter: ConversationFilter,
private val messageRepo: MessageRepository,
private val markAllSeen: MarkAllSeen,
private val deleteConversations: DeleteConversations,
@@ -59,9 +59,7 @@ class MainViewModel @Inject constructor(
private val ratingManager: RatingManager,
private val syncMessages: SyncMessages,
private val syncRepository: SyncRepository
-) : QkViewModel(MainState()) {
-
- private val conversations = messageRepo.getConversations()
+) : QkViewModel(MainState(page = Inbox(data = messageRepo.getConversations()))) {
init {
disposables += deleteConversations
@@ -132,18 +130,25 @@ class MainViewModel @Inject constructor(
view.queryChangedIntent
.debounce(200, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
.map { query -> query.removeAccents() }
.withLatestFrom(state, { query, state ->
- if (state.page is Inbox) {
- val filteredConversations = if (query.isEmpty()) conversations
- else conversations.map { conversations -> conversations.filter { conversationFilter.filter(it, query) } }
-
- val page = state.page.copy(showClearButton = query.isNotEmpty(), data = filteredConversations)
- newState { it.copy(page = page) }
+ if (query.isEmpty() && state.page is Searching) {
+ newState { it.copy(page = Inbox(data = messageRepo.getConversations())) }
}
+ query
})
+ .filter { query -> query.length >= 2 }
+ .doOnNext {
+ newState { state ->
+ val page = (state.page as? Searching) ?: Searching()
+ state.copy(page = page.copy(loading = true))
+ }
+ }
+ .observeOn(Schedulers.io())
+ .switchMap { query -> Observable.just(query).map { messageRepo.searchConversations(it) } }
.autoDisposable(view.scope())
- .subscribe()
+ .subscribe { data -> newState { it.copy(page = Searching(loading = false, data = data)) } }
view.composeIntent
.autoDisposable(view.scope())
@@ -152,6 +157,8 @@ class MainViewModel @Inject constructor(
view.homeIntent
.withLatestFrom(state, { _, state ->
when {
+ state.page is Searching -> view.clearSearch()
+
state.page is Inbox && state.page.selected > 0 -> view.clearSelection()
state.page is Inbox && state.page.showClearButton -> view.clearSearch()
@@ -175,7 +182,7 @@ class MainViewModel @Inject constructor(
.distinctUntilChanged()
.doOnNext {
when (it) {
- DrawerItem.INBOX -> newState { it.copy(page = Inbox(data = conversations)) }
+ DrawerItem.INBOX -> newState { it.copy(page = Inbox(data = messageRepo.getConversations())) }
DrawerItem.ARCHIVED -> newState { it.copy(page = Archived(data = messageRepo.getConversations(true))) }
DrawerItem.SCHEDULED -> newState { it.copy(page = Scheduled()) }
else -> {
@@ -289,12 +296,14 @@ class MainViewModel @Inject constructor(
when {
state.drawerOpen -> newState { it.copy(drawerOpen = false) }
+ state.page is Searching -> view.clearSearch()
+
state.page is Inbox && state.page.selected > 0 -> view.clearSelection()
state.page is Inbox && state.page.showClearButton -> view.clearSearch()
state.page is Archived && state.page.selected > 0 -> view.clearSelection()
- state.page !is Inbox -> newState { it.copy(page = Inbox(data = conversations)) }
+ state.page !is Inbox -> newState { it.copy(page = Inbox(data = messageRepo.getConversations())) }
else -> newState { it.copy(hasError = true) }
}
diff --git a/presentation/src/main/java/feature/main/SearchAdapter.kt b/presentation/src/main/java/feature/main/SearchAdapter.kt
new file mode 100644
index 00000000..c7e8173d
--- /dev/null
+++ b/presentation/src/main/java/feature/main/SearchAdapter.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2017 Moez Bhatti
+ *
+ * This file is part of QKSMS.
+ *
+ * QKSMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * QKSMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with QKSMS. If not, see .
+ */
+package feature.main
+
+import android.content.Context
+import android.support.v7.widget.RecyclerView
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.BackgroundColorSpan
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import com.jakewharton.rxbinding2.view.clicks
+import com.moez.QKSMS.R
+import common.Navigator
+import common.base.QkAdapter
+import common.base.QkViewHolder
+import common.util.Colors
+import common.util.DateFormatter
+import common.util.extensions.setVisible
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.rxkotlin.plusAssign
+import kotlinx.android.synthetic.main.search_list_item.view.*
+import model.SearchResult
+import javax.inject.Inject
+
+class SearchAdapter @Inject constructor(
+ private val context: Context,
+ private val colors: Colors,
+ private val dateFormatter: DateFormatter,
+ private val navigator: Navigator
+) : QkAdapter() {
+
+ private var highlightColor: Int = 0
+
+ private val disposables = CompositeDisposable()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder {
+ val layoutInflater = LayoutInflater.from(context)
+ val view = layoutInflater.inflate(R.layout.search_list_item, parent, false)
+ return QkViewHolder(view)
+ }
+
+ override fun onBindViewHolder(viewHolder: QkViewHolder, position: Int) {
+ val previous = data.getOrNull(position - 1)
+ val result = getItem(position)
+ val view = viewHolder.itemView
+
+ view.clicks().subscribe {
+ navigator.showConversation(result.conversation.id, result.query.takeIf { result.messages > 0 })
+ }
+
+ view.resultsHeader.setVisible(result.messages > 0 && previous?.messages == 0)
+
+ val query = result.query
+ val title = SpannableString(result.conversation.getTitle())
+ var index = title.indexOf(query, ignoreCase = true)
+
+ while (index >= 0) {
+ title.setSpan(BackgroundColorSpan(highlightColor), index, index + query.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ index = title.indexOf(query, index + query.length, true)
+ }
+ view.title.text = title
+
+ view.avatars.contacts = result.conversation.recipients
+
+ when (result.messages == 0) {
+ true -> {
+ view.date.setVisible(true)
+ view.date.text = dateFormatter.getConversationTimestamp(result.conversation.date)
+ view.snippet.text = when (result.conversation.me) {
+ true -> context.getString(R.string.main_sender_you, result.conversation.snippet)
+ false -> result.conversation.snippet
+ }
+ }
+
+ false -> {
+ view.date.setVisible(false)
+ view.snippet.text = context.getString(R.string.main_message_results, result.messages)
+ }
+ }
+ }
+
+ override fun areItemsTheSame(old: SearchResult, new: SearchResult): Boolean {
+ return old.conversation.id == new.conversation.id && old.messages > 0 == new.messages > 0
+ }
+
+ override fun areContentsTheSame(old: SearchResult, new: SearchResult): Boolean {
+ return old.query == new.query && // Queries are the same
+ old.conversation.id == new.conversation.id // Conversation id is the same
+ && old.messages == new.messages // Result count is the same
+ }
+
+ override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ disposables += colors.highlightColor
+ .doOnNext { color -> highlightColor = color }
+ .subscribe { notifyDataSetChanged() }
+ }
+
+ override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
+ disposables.dispose()
+ }
+}
\ No newline at end of file
diff --git a/presentation/src/main/res/drawable/ic_clear_black_24dp.xml b/presentation/src/main/res/drawable/ic_clear_black_24dp.xml
new file mode 100644
index 00000000..ede4b710
--- /dev/null
+++ b/presentation/src/main/res/drawable/ic_clear_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/src/main/res/drawable/ic_keyboard_arrow_down_black_24dp.xml b/presentation/src/main/res/drawable/ic_keyboard_arrow_down_black_24dp.xml
new file mode 100644
index 00000000..ad33063c
--- /dev/null
+++ b/presentation/src/main/res/drawable/ic_keyboard_arrow_down_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/src/main/res/drawable/ic_keyboard_arrow_up_black_24dp.xml b/presentation/src/main/res/drawable/ic_keyboard_arrow_up_black_24dp.xml
new file mode 100644
index 00000000..57387ee4
--- /dev/null
+++ b/presentation/src/main/res/drawable/ic_keyboard_arrow_up_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/src/main/res/layout/compose_activity.xml b/presentation/src/main/res/layout/compose_activity.xml
index 12c25dea..3f98db6d 100644
--- a/presentation/src/main/res/layout/compose_activity.xml
+++ b/presentation/src/main/res/layout/compose_activity.xml
@@ -224,17 +224,35 @@
app:layout_constraintTop_toTopOf="parent"
tools:background="@color/toolbarLight">
-
-
-
+ android:layout_height="match_parent"
+ android:gravity="center_vertical"
+ android:orientation="vertical">
+
+
+
+
+
+
+
+
diff --git a/presentation/src/main/res/layout/search_list_item.xml b/presentation/src/main/res/layout/search_list_item.xml
new file mode 100644
index 00000000..0b7e2d34
--- /dev/null
+++ b/presentation/src/main/res/layout/search_list_item.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/presentation/src/main/res/menu/compose.xml b/presentation/src/main/res/menu/compose.xml
index 9848cda4..d74e426b 100644
--- a/presentation/src/main/res/menu/compose.xml
+++ b/presentation/src/main/res/menu/compose.xml
@@ -54,5 +54,26 @@
android:visible="false"
app:showAsAction="ifRoom" />
+
+
+
+
+
+
\ No newline at end of file
diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml
index 1856218d..890ddfcc 100644
--- a/presentation/src/main/res/values/strings.xml
+++ b/presentation/src/main/res/values/strings.xml
@@ -44,6 +44,8 @@
Delete
Syncing messagesā¦
You: %s
+ Results in messages
+ %d messages
Your conversations will appear here
No results
Your archived conversations will appear here
@@ -80,11 +82,15 @@
%d selected
+ %1$d of %2$d results
This is the start of your conversation. Say something nice!
Write a messageā¦
Copy text
Forward
Delete
+ Previous
+ Next
+ Clear
Add an attachment
Attach a photo
Take a photo