From e3307bf3dc1816ffe8aba38a60f035c56cebd8b0 Mon Sep 17 00:00:00 2001 From: Moez Bhatti Date: Thu, 17 May 2018 15:31:34 -0400 Subject: [PATCH] #1010 - Message content search --- .../src/main/java}/filter/ContactFilter.kt | 2 +- .../main/java}/filter/ConversationFilter.kt | 2 +- .../src/main/java}/filter/Filter.kt | 2 +- .../main/java}/filter/PhoneNumberFilter.kt | 2 +- .../src/main/java}/filter/RecipientFilter.kt | 2 +- .../java/repository/MessageRepositoryImpl.kt | 36 ++++-- domain/src/main/java/model/SearchResult.kt | 3 + .../main/java/repository/MessageRepository.kt | 8 +- .../src/main/java/common/Navigator.kt | 5 +- .../main/java/common/base/FlowableAdapter.kt | 13 -- .../src/main/java/common/base/QkAdapter.kt | 15 ++- .../main/java/common/base/QkRealmAdapter.kt | 2 + .../main/java/common/base/QkThemedActivity.kt | 10 +- .../src/main/java/common/util/Colors.kt | 5 + .../java/feature/compose/ComposeActivity.kt | 29 ++++- .../main/java/feature/compose/ComposeState.kt | 4 + .../main/java/feature/compose/ComposeView.kt | 1 + .../java/feature/compose/ComposeViewModel.kt | 72 ++++++++++- .../java/feature/compose/MessagesAdapter.kt | 13 +- .../conversations/ConversationsAdapter.kt | 14 +-- .../main/java/feature/main/MainActivity.kt | 20 +-- .../src/main/java/feature/main/MainState.kt | 12 +- .../main/java/feature/main/MainViewModel.kt | 37 +++--- .../main/java/feature/main/SearchAdapter.kt | 118 ++++++++++++++++++ .../main/res/drawable/ic_clear_black_24dp.xml | 9 ++ .../ic_keyboard_arrow_down_black_24dp.xml | 9 ++ .../ic_keyboard_arrow_up_black_24dp.xml | 9 ++ .../src/main/res/layout/compose_activity.xml | 38 ++++-- .../src/main/res/layout/search_list_item.xml | 112 +++++++++++++++++ presentation/src/main/res/menu/compose.xml | 21 ++++ presentation/src/main/res/values/strings.xml | 6 + 31 files changed, 540 insertions(+), 91 deletions(-) rename {presentation/src/main/java/common/util => data/src/main/java}/filter/ContactFilter.kt (97%) rename {presentation/src/main/java/common/util => data/src/main/java}/filter/ConversationFilter.kt (97%) rename {presentation/src/main/java/common/util => data/src/main/java}/filter/Filter.kt (96%) rename {presentation/src/main/java/common/util => data/src/main/java}/filter/PhoneNumberFilter.kt (97%) rename {presentation/src/main/java/common/util => data/src/main/java}/filter/RecipientFilter.kt (97%) create mode 100644 domain/src/main/java/model/SearchResult.kt create mode 100644 presentation/src/main/java/feature/main/SearchAdapter.kt create mode 100644 presentation/src/main/res/drawable/ic_clear_black_24dp.xml create mode 100644 presentation/src/main/res/drawable/ic_keyboard_arrow_down_black_24dp.xml create mode 100644 presentation/src/main/res/drawable/ic_keyboard_arrow_up_black_24dp.xml create mode 100644 presentation/src/main/res/layout/search_list_item.xml 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