diff --git a/data/src/main/java/com/moez/QKSMS/repository/ContactRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/ContactRepositoryImpl.kt index 268913a6..ee3d25fa 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/ContactRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/ContactRepositoryImpl.kt @@ -73,6 +73,15 @@ class ContactRepositoryImpl @Inject constructor( .findAll() } + override fun getUnmanagedContact(lookupKey: String): Contact? { + return Realm.getDefaultInstance().use { realm -> + realm.where(Contact::class.java) + .equalTo("lookupKey", lookupKey) + .findFirst() + ?.let(realm::copyFromRealm) + } + } + override fun getUnmanagedContacts(starred: Boolean): Observable> { val realm = Realm.getDefaultInstance() diff --git a/domain/build.gradle b/domain/build.gradle index 97817fa4..a018510e 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -19,6 +19,7 @@ apply plugin: 'com.android.library' apply plugin: 'realm-android' // Realm needs to be before Kotlin or the build will fail apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' android { diff --git a/domain/src/main/java/com/moez/QKSMS/repository/ContactRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/ContactRepository.kt index 4242afb5..b6f8011b 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ContactRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/ContactRepository.kt @@ -31,6 +31,8 @@ interface ContactRepository { fun getContacts(): RealmResults + fun getUnmanagedContact(lookupKey: String): Contact? + fun getUnmanagedContacts(starred: Boolean = false): Observable> fun getUnmanagedContactGroups(): Observable> diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 9301275c..08e36b7a 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -109,6 +109,7 @@ android:windowSoftInputMode="adjustResize" /> + diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt index 02d39543..be4028a5 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt @@ -41,7 +41,6 @@ import androidx.lifecycle.ViewModelProviders import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.material.snackbar.Snackbar import com.jakewharton.rxbinding2.view.clicks -import com.jakewharton.rxbinding2.widget.editorActions import com.jakewharton.rxbinding2.widget.textChanges import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator @@ -54,14 +53,9 @@ import com.moez.QKSMS.common.util.extensions.setBackgroundTint import com.moez.QKSMS.common.util.extensions.setTint import com.moez.QKSMS.common.util.extensions.setVisible import com.moez.QKSMS.common.util.extensions.showKeyboard -import com.moez.QKSMS.common.widget.QkDialog -import com.moez.QKSMS.extensions.Optional import com.moez.QKSMS.feature.compose.editing.Chip import com.moez.QKSMS.feature.compose.editing.ChipsAdapter -import com.moez.QKSMS.feature.compose.editing.ComposeItem -import com.moez.QKSMS.feature.compose.editing.ComposeItemAdapter -import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction -import com.moez.QKSMS.feature.compose.editing.PhoneNumberPickerAdapter +import com.moez.QKSMS.feature.contacts.ContactsActivity import com.moez.QKSMS.model.Attachment import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable @@ -73,32 +67,26 @@ import kotlinx.android.synthetic.main.compose_activity.* import java.text.SimpleDateFormat import java.util.* import javax.inject.Inject +import kotlin.collections.HashMap class ComposeActivity : QkThemedActivity(), ComposeView { companion object { - private const val CAMERA_REQUEST_CODE = 0 - private const val GALLERY_REQUEST_CODE = 1 - private const val CONTACT_REQUEST_CODE = 2 + private const val SELECT_CONTACT_REQUEST_CODE = 0 + private const val TAKE_PHOTO_REQUEST_CODE = 1 + private const val ATTACH_PHOTO_REQUEST_CODE = 2 + private const val ATTACH_CONTACT_REQUEST_CODE = 3 } @Inject lateinit var attachmentAdapter: AttachmentAdapter @Inject lateinit var chipsAdapter: ChipsAdapter - @Inject lateinit var contactsAdapter: ComposeItemAdapter @Inject lateinit var dateFormatter: DateFormatter @Inject lateinit var messageAdapter: MessagesAdapter @Inject lateinit var navigator: Navigator - @Inject lateinit var phoneNumberAdapter: PhoneNumberPickerAdapter @Inject lateinit var viewModelFactory: ViewModelProvider.Factory override val activityVisibleIntent: Subject = PublishSubject.create() - override val queryChangedIntent: Observable by lazy { search.textChanges() } - override val queryBackspaceIntent: Observable<*> by lazy { search.backspaces } - override val queryEditorActionIntent: Observable by lazy { search.editorActions() } - override val composeItemPressedIntent: Subject by lazy { contactsAdapter.clicks } - override val composeItemLongPressedIntent: Subject by lazy { contactsAdapter.longClicks } - override val phoneNumberSelectedIntent: Subject> by lazy { phoneNumberAdapter.selectedItemChanges } - override val phoneNumberActionIntent: Subject = PublishSubject.create() + override val chipsSelectedIntent: Subject> = PublishSubject.create() override val chipDeletedIntent: Subject by lazy { chipsAdapter.chipDeleted } override val menuReadyIntent: Observable = menu.map { Unit } override val optionsItemIntent: Subject = PublishSubject.create() @@ -124,18 +112,6 @@ class ComposeActivity : QkThemedActivity(), ComposeView { override val viewQksmsPlusIntent: Subject = PublishSubject.create() override val backPressedIntent: Subject = PublishSubject.create() - private val phoneNumberDialog by lazy { - QkDialog(this).apply { - titleRes = R.string.compose_number_picker_title - adapter = phoneNumberAdapter - positiveButton = R.string.compose_number_picker_always - positiveButtonListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.ALWAYS) } - negativeButton = R.string.compose_number_picker_once - negativeButtonListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.JUST_ONCE) } - cancelListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.CANCEL) } - } - } - private val viewModel by lazy { ViewModelProviders.of(this, viewModelFactory)[ComposeViewModel::class.java] } private var cameraDestination: Uri? = null @@ -153,7 +129,6 @@ class ComposeActivity : QkThemedActivity(), ComposeView { chipsAdapter.view = chips - contacts.itemAnimator = null chips.itemAnimator = null chips.layoutManager = FlexboxLayoutManager(this) @@ -209,19 +184,17 @@ class ComposeActivity : QkThemedActivity(), ComposeView { } toolbarSubtitle.setVisible(state.query.isNotEmpty()) - toolbarSubtitle.text = getString(R.string.compose_subtitle_results, state.searchSelectionPosition, state.searchResults) + toolbarSubtitle.text = getString(R.string.compose_subtitle_results, state.searchSelectionPosition, + state.searchResults) toolbarTitle.setVisible(!state.editingMode) - chips.setVisible(state.editingMode && !state.searching) - search.setVisible(state.editingMode && state.searching) - contacts.setVisible(state.editingMode && state.searching) - composeBar.setVisible(!state.searching && !state.loading) + chips.setVisible(state.editingMode) + composeBar.setVisible(!state.loading) // Don't set the adapters unless needed if (state.editingMode && chips.adapter == null) chips.adapter = chipsAdapter - if (state.editingMode && contacts.adapter == null) contacts.adapter = contactsAdapter - toolbar.menu.findItem(R.id.add)?.isVisible = state.editingMode && !state.searching + toolbar.menu.findItem(R.id.add)?.isVisible = state.editingMode 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 @@ -239,15 +212,6 @@ class ComposeActivity : QkThemedActivity(), ComposeView { } chipsAdapter.data = state.selectedChips - contactsAdapter.data = state.composeItems - - if (state.selectedContact != null && !phoneNumberDialog.isShowing) { - phoneNumberAdapter.data = state.selectedContact.numbers - phoneNumberDialog.subtitle = state.selectedContact.name - phoneNumberDialog.show() - } else if (state.selectedContact == null && phoneNumberDialog.isShowing) { - phoneNumberDialog.dismiss() - } loading.setVisible(state.loading) @@ -321,7 +285,14 @@ class ComposeActivity : QkThemedActivity(), ComposeView { val intent = Intent(Intent.ACTION_PICK) .setType(ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE) - startActivityForResult(Intent.createChooser(intent, null), CONTACT_REQUEST_CODE) + startActivityForResult(Intent.createChooser(intent, null), ATTACH_CONTACT_REQUEST_CODE) + } + + override fun showContacts(chips: List) { + val serialized = HashMap(chips.associate { chip -> chip.address to chip.contact?.lookupKey }) + val intent = Intent(this, ContactsActivity::class.java) + .putExtra(ContactsActivity.ChipsKey, serialized) + startActivityForResult(intent, SELECT_CONTACT_REQUEST_CODE) } override fun requestCamera() { @@ -331,17 +302,17 @@ class ComposeActivity : QkThemedActivity(), ComposeView { val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) .putExtra(MediaStore.EXTRA_OUTPUT, cameraDestination) - startActivityForResult(Intent.createChooser(intent, null), CAMERA_REQUEST_CODE) + startActivityForResult(Intent.createChooser(intent, null), TAKE_PHOTO_REQUEST_CODE) } override fun requestGallery() { val intent = Intent(Intent.ACTION_PICK) - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) .putExtra(Intent.EXTRA_LOCAL_ONLY, false) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setType("image/*") - startActivityForResult(Intent.createChooser(intent, null), GALLERY_REQUEST_CODE) + startActivityForResult(Intent.createChooser(intent, null), ATTACH_PHOTO_REQUEST_CODE) } override fun setDraft(draft: String) = message.setText(draft) @@ -376,15 +347,29 @@ class ComposeActivity : QkThemedActivity(), ComposeView { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (resultCode == Activity.RESULT_OK) { - when (requestCode) { - CAMERA_REQUEST_CODE -> cameraDestination?.let(attachmentSelectedIntent::onNext) - GALLERY_REQUEST_CODE -> data?.data?.let(attachmentSelectedIntent::onNext) - CONTACT_REQUEST_CODE -> data?.data?.let(contactSelectedIntent::onNext) + when { + requestCode == SELECT_CONTACT_REQUEST_CODE -> { + chipsSelectedIntent.onNext(data?.getSerializableExtra(ContactsActivity.ChipsKey) + ?.let { serializable -> serializable as? HashMap } + ?: hashMapOf()) } + requestCode == TAKE_PHOTO_REQUEST_CODE && resultCode == Activity.RESULT_OK -> { + cameraDestination?.let(attachmentSelectedIntent::onNext) + } + requestCode == ATTACH_PHOTO_REQUEST_CODE && resultCode == Activity.RESULT_OK -> { + data?.clipData?.itemCount + ?.let { count -> 0 until count } + ?.mapNotNull { i -> data.clipData?.getItemAt(i)?.uri } + ?.forEach(attachmentSelectedIntent::onNext) + ?: data?.data?.let(attachmentSelectedIntent::onNext) + } + requestCode == ATTACH_CONTACT_REQUEST_CODE && resultCode == Activity.RESULT_OK -> { + data?.data?.let(contactSelectedIntent::onNext) + } + else -> super.onActivityResult(requestCode, resultCode, data) } } override fun onBackPressed() = backPressedIntent.onNext(Unit) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt index f7b7e7b8..3d6babaf 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt @@ -20,9 +20,7 @@ package com.moez.QKSMS.feature.compose import com.moez.QKSMS.compat.SubscriptionInfoCompat import com.moez.QKSMS.feature.compose.editing.Chip -import com.moez.QKSMS.feature.compose.editing.ComposeItem import com.moez.QKSMS.model.Attachment -import com.moez.QKSMS.model.Contact import com.moez.QKSMS.model.Conversation import com.moez.QKSMS.model.Message import io.realm.RealmResults @@ -30,10 +28,7 @@ import io.realm.RealmResults data class ComposeState( val hasError: Boolean = false, val editingMode: Boolean = false, - val searching: Boolean = false, - val composeItems: List = ArrayList(), val selectedConversation: Long = 0, - val selectedContact: Contact? = null, // For phone number picker val selectedChips: List = ArrayList(), val sendAsGroup: Boolean = true, val conversationtitle: String = "", diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt index 4c4af90f..aa54543e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt @@ -22,10 +22,7 @@ import android.net.Uri import androidx.annotation.StringRes import androidx.core.view.inputmethod.InputContentInfoCompat import com.moez.QKSMS.common.base.QkView -import com.moez.QKSMS.extensions.Optional import com.moez.QKSMS.feature.compose.editing.Chip -import com.moez.QKSMS.feature.compose.editing.ComposeItem -import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction import com.moez.QKSMS.model.Attachment import io.reactivex.Observable import io.reactivex.subjects.Subject @@ -33,13 +30,7 @@ import io.reactivex.subjects.Subject interface ComposeView : QkView { val activityVisibleIntent: Observable - val queryChangedIntent: Observable - val queryBackspaceIntent: Observable<*> - val queryEditorActionIntent: Observable - val composeItemPressedIntent: Subject - val composeItemLongPressedIntent: Subject - val phoneNumberSelectedIntent: Subject> - val phoneNumberActionIntent: Subject + val chipsSelectedIntent: Subject> val chipDeletedIntent: Subject val menuReadyIntent: Observable val optionsItemIntent: Observable @@ -70,6 +61,7 @@ interface ComposeView : QkView { fun requestDefaultSms() fun requestStoragePermission() fun requestSmsPermission() + fun showContacts(chips: List) fun requestCamera() fun requestGallery() fun requestDatePicker() diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt index 3d00fcdd..fb8db5b0 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt @@ -22,7 +22,6 @@ import android.content.Context import android.net.Uri import android.provider.ContactsContract import android.telephony.SmsMessage -import android.view.inputmethod.EditorInfo import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkViewModel @@ -36,35 +35,23 @@ import com.moez.QKSMS.extensions.asObservable import com.moez.QKSMS.extensions.isImage import com.moez.QKSMS.extensions.isVideo import com.moez.QKSMS.extensions.mapNotNull -import com.moez.QKSMS.extensions.removeAccents import com.moez.QKSMS.feature.compose.editing.Chip -import com.moez.QKSMS.feature.compose.editing.ComposeItem -import com.moez.QKSMS.feature.compose.editing.ComposeItem.* -import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction -import com.moez.QKSMS.filter.ContactFilter -import com.moez.QKSMS.filter.ContactGroupFilter import com.moez.QKSMS.interactor.AddScheduledMessage import com.moez.QKSMS.interactor.CancelDelayedMessage -import com.moez.QKSMS.interactor.ContactSync import com.moez.QKSMS.interactor.DeleteMessages import com.moez.QKSMS.interactor.MarkRead import com.moez.QKSMS.interactor.RetrySending import com.moez.QKSMS.interactor.SendMessage -import com.moez.QKSMS.interactor.SetDefaultPhoneNumber import com.moez.QKSMS.manager.ActiveConversationManager import com.moez.QKSMS.manager.PermissionManager import com.moez.QKSMS.model.Attachment import com.moez.QKSMS.model.Attachments -import com.moez.QKSMS.model.Contact -import com.moez.QKSMS.model.ContactGroup import com.moez.QKSMS.model.Conversation import com.moez.QKSMS.model.Message -import com.moez.QKSMS.model.PhoneNumber import com.moez.QKSMS.repository.ContactRepository import com.moez.QKSMS.repository.ConversationRepository import com.moez.QKSMS.repository.MessageRepository import com.moez.QKSMS.util.ActiveSubscriptionObservable -import com.moez.QKSMS.util.PhoneNumberUtils import com.moez.QKSMS.util.Preferences import com.moez.QKSMS.util.tryOrNull import com.uber.autodispose.android.lifecycle.scope @@ -78,9 +65,6 @@ import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject -import io.realm.RealmList -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.rx2.awaitFirst import timber.log.Timber import java.util.* import javax.inject.Inject @@ -92,14 +76,12 @@ class ComposeViewModel @Inject constructor( @Named("address") private val address: String, @Named("text") private val sharedText: String, @Named("attachments") private val sharedAttachments: Attachments, + private val contactRepo: ContactRepository, private val context: Context, private val activeConversationManager: ActiveConversationManager, private val addScheduledMessage: AddScheduledMessage, private val billingManager: BillingManager, private val cancelMessage: CancelDelayedMessage, - private val contactFilter: ContactFilter, - private val contactGroupFilter: ContactGroupFilter, - private val contactsRepo: ContactRepository, private val conversationRepo: ConversationRepository, private val deleteMessages: DeleteMessages, private val markRead: MarkRead, @@ -107,31 +89,25 @@ class ComposeViewModel @Inject constructor( private val messageRepo: MessageRepository, private val navigator: Navigator, private val permissionManager: PermissionManager, - private val phoneNumberUtils: PhoneNumberUtils, private val prefs: Preferences, private val retrySending: RetrySending, private val sendMessage: SendMessage, - private val setDefaultPhoneNumber: SetDefaultPhoneNumber, - private val subscriptionManager: SubscriptionManagerCompat, - private val syncContacts: ContactSync + private val subscriptionManager: SubscriptionManagerCompat ) : QkViewModel(ComposeState( editingMode = threadId == 0L && address.isBlank(), - searching = threadId == 0L && address.isBlank(), selectedConversation = threadId, query = query) ) { private val attachments: Subject> = BehaviorSubject.createDefault(sharedAttachments) - private val contactGroups: Observable> by lazy { contactsRepo.getUnmanagedContactGroups() } - private val contacts: Observable> by lazy { contactsRepo.getUnmanagedContacts() } private val chipsReducer: Subject<(List) -> List> = PublishSubject.create() private val conversation: Subject = BehaviorSubject.create() private val messages: Subject> = BehaviorSubject.create() - private val recents: Observable> by lazy { conversationRepo.getUnmanagedConversations() } private val selectedChips: Subject> = BehaviorSubject.createDefault(listOf()) private val searchResults: Subject> = BehaviorSubject.create() private val searchSelection: Subject = BehaviorSubject.createDefault(-1) - private val starredContacts: Observable> by lazy { contactsRepo.getUnmanagedContacts(true) } + + private var shouldShowContacts = threadId == 0L && address.isBlank() init { val initialConversation = threadId.takeIf { it != 0L } @@ -241,135 +217,51 @@ class ComposeViewModel @Inject constructor( val sub = if (subs.size > 1) subs.firstOrNull { it.subscriptionId == subId } ?: subs[0] else null newState { copy(subscription = sub) } }.subscribe() - - if (threadId == 0L) { - syncContacts.execute(Unit) - } } override fun bindView(view: ComposeView) { super.bindView(view) + if (shouldShowContacts) { + shouldShowContacts = false + view.showContacts(selectedChips.blockingFirst()) + } + + view.chipsSelectedIntent + .withLatestFrom(selectedChips) { hashmap, chips -> + if (hashmap.isEmpty() && chips.isEmpty()) { + newState { copy(hasError = true) } + } + hashmap + } + .filter { hashmap -> hashmap.isNotEmpty() } + .map { hashmap -> + hashmap.map { (address, lookupKey) -> + Chip(address, lookupKey?.let(contactRepo::getUnmanagedContact)) + } + } + .autoDisposable(view.scope()) + .subscribe { chips -> + chipsReducer.onNext { list -> list + chips } + } + // Set the contact suggestions list to visible when the add button is pressed view.optionsItemIntent .filter { it == R.id.add } - .skipUntil(state.filter { state -> state.editingMode }) - .takeUntil(state.filter { state -> !state.editingMode }) - .autoDisposable(view.scope()) - .subscribe { newState { copy(searching = true) } } - - // Update the list of contact suggestions based on the query input, while also filtering out any contacts - // that have already been selected - Observables - .combineLatest( - view.queryChangedIntent, recents, starredContacts, contactGroups, contacts, selectedChips - ) { query, recents, starredContacts, contactGroups, contacts, selectedChips -> - val composeItems = mutableListOf() - if (query.isBlank()) { - composeItems += recents.map(::Recent) - composeItems += starredContacts.map(::Starred) - composeItems += contactGroups.map(::Group) - composeItems += contacts.map(::Person) - } else { - // If the entry is a valid destination, allow it as a recipient - if (phoneNumberUtils.isPossibleNumber(query.toString())) { - val newAddress = phoneNumberUtils.formatNumber(query) - val newContact = Contact(numbers = RealmList(PhoneNumber(address = newAddress))) - composeItems += New(newContact) - } - - // Strip the accents from the query. This can be an expensive operation, so - // cache the result instead of doing it for each contact - val normalizedQuery = query.removeAccents() - composeItems += starredContacts - .filterNot { contact -> selectedChips.map { it.contact }.contains(contact) } - .filter { contact -> contactFilter.filter(contact, normalizedQuery) } - .map(::Starred) - - composeItems += contactGroups - .filter { group -> contactGroupFilter.filter(group, normalizedQuery) } - .map(::Group) - - composeItems += contacts - .filterNot { contact -> selectedChips.map { it.contact }.contains(contact) } - .filter { contact -> contactFilter.filter(contact, normalizedQuery) } - .map(::Person) - } - - composeItems - } - .skipUntil(state.filter { state -> state.editingMode }) - .takeUntil(state.filter { state -> !state.editingMode }) - .subscribeOn(Schedulers.computation()) - .autoDisposable(view.scope()) - .subscribe { items -> newState { copy(composeItems = items) } } - - // Backspaces should delete the most recent contact if there's no text input - // Close the activity if user presses back - view.queryBackspaceIntent - .withLatestFrom(selectedChips, view.queryChangedIntent) { event, contacts, query -> - if (contacts.isNotEmpty() && query.isEmpty()) { - chipsReducer.onNext { it.dropLast(1) } - } + .withLatestFrom(selectedChips) { _, chips -> + view.showContacts(chips) } .autoDisposable(view.scope()) .subscribe() - // Listen for ComposeItems being selected, and then send them off to the number picker dialog in case - // the user needs to select a phone number - view.queryEditorActionIntent - .filter { actionId -> actionId == EditorInfo.IME_ACTION_DONE } - .withLatestFrom(state) { _, state -> state } - .mapNotNull { state -> state.composeItems.firstOrNull() } - .mergeWith(view.composeItemPressedIntent) - .map { composeItem -> composeItem to false } - .mergeWith(view.composeItemLongPressedIntent.map { composeItem -> composeItem to true }) - .observeOn(Schedulers.io()) - .skipUntil(state.filter { state -> state.editingMode }) - .takeUntil(state.filter { state -> !state.editingMode }) - .autoDisposable(view.scope()) - .subscribe { (composeItem, force) -> - val contacts = composeItem.getContacts() - val newChips = contacts.map { contact -> - if (contact.numbers.size == 1 || contact.getDefaultNumber() != null && !force) { - val number = contact.getDefaultNumber() ?: contact.numbers[0]!! - Chip(number.address, contact) - } else { - runBlocking { - newState { copy(selectedContact = contact) } - val action = view.phoneNumberActionIntent.awaitFirst() - newState { copy(selectedContact = null) } - val numberId = view.phoneNumberSelectedIntent.awaitFirst().value - val number = contact.numbers.find { number -> number.id == numberId } - - if (action == PhoneNumberAction.CANCEL || number == null) { - return@runBlocking null - } - - if (action == PhoneNumberAction.ALWAYS) { - val params = SetDefaultPhoneNumber.Params(contact.lookupKey, number.id) - setDefaultPhoneNumber.execute(params) - } - - Chip(number.address, contact) - } ?: return@subscribe - } - } - - newState { copy(searching = false) } - chipsReducer.onNext { chips -> chips + newChips } - } - // Update the list of selected contacts when a new contact is selected or an existing one is deselected view.chipDeletedIntent - .skipUntil(state.filter { state -> state.editingMode }) - .takeUntil(state.filter { state -> !state.editingMode }) .autoDisposable(view.scope()) .subscribe { contact -> chipsReducer.onNext { contacts -> val result = contacts.filterNot { it == contact } if (result.isEmpty()) { - newState { copy(searching = true) } + view.showContacts(result) } result } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivity.kt new file mode 100644 index 00000000..3685941c --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivity.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.lifecycle.ViewModelProviders +import com.jakewharton.rxbinding2.widget.editorActions +import com.jakewharton.rxbinding2.widget.textChanges +import com.moez.QKSMS.R +import com.moez.QKSMS.common.ViewModelFactory +import com.moez.QKSMS.common.base.QkThemedActivity +import com.moez.QKSMS.common.widget.QkDialog +import com.moez.QKSMS.extensions.Optional +import com.moez.QKSMS.feature.compose.editing.ComposeItem +import com.moez.QKSMS.feature.compose.editing.ComposeItemAdapter +import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction +import com.moez.QKSMS.feature.compose.editing.PhoneNumberPickerAdapter +import dagger.android.AndroidInjection +import io.reactivex.Observable +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.contacts_activity.* +import javax.inject.Inject + +class ContactsActivity : QkThemedActivity(), ContactsContract { + + companion object { + const val ChipsKey = "chips" + } + + @Inject lateinit var contactsAdapter: ComposeItemAdapter + @Inject lateinit var phoneNumberAdapter: PhoneNumberPickerAdapter + @Inject lateinit var viewModelFactory: ViewModelFactory + + override val queryChangedIntent: Observable by lazy { search.textChanges() } + override val queryBackspaceIntent: Observable<*> by lazy { search.backspaces } + override val queryEditorActionIntent: Observable by lazy { search.editorActions() } + override val composeItemPressedIntent: Subject by lazy { contactsAdapter.clicks } + override val composeItemLongPressedIntent: Subject by lazy { contactsAdapter.longClicks } + override val phoneNumberSelectedIntent: Subject> by lazy { phoneNumberAdapter.selectedItemChanges } + override val phoneNumberActionIntent: Subject = PublishSubject.create() + + private val viewModel by lazy { ViewModelProviders.of(this, viewModelFactory)[ContactsViewModel::class.java] } + + private val phoneNumberDialog by lazy { + QkDialog(this).apply { + titleRes = R.string.compose_number_picker_title + adapter = phoneNumberAdapter + positiveButton = R.string.compose_number_picker_always + positiveButtonListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.ALWAYS) } + negativeButton = R.string.compose_number_picker_once + negativeButtonListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.JUST_ONCE) } + cancelListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.CANCEL) } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidInjection.inject(this) + super.onCreate(savedInstanceState) + setContentView(R.layout.contacts_activity) + showBackButton(true) + viewModel.bindView(this) + + contacts.itemAnimator = null + contacts.adapter = contactsAdapter + } + + override fun render(state: ContactsState) { + contactsAdapter.data = state.composeItems + + if (state.selectedContact != null && !phoneNumberDialog.isShowing) { + phoneNumberAdapter.data = state.selectedContact.numbers + phoneNumberDialog.subtitle = state.selectedContact.name + phoneNumberDialog.show() + } else if (state.selectedContact == null && phoneNumberDialog.isShowing) { + phoneNumberDialog.dismiss() + } + } + + override fun finish(result: HashMap) { + val intent = Intent().putExtra(ChipsKey, result) + setResult(Activity.RESULT_OK, intent) + finish() + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivityModule.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivityModule.kt new file mode 100644 index 00000000..d8b832f8 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivityModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import androidx.lifecycle.ViewModel +import com.moez.QKSMS.injection.ViewModelKey +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap + +@Module +class ContactsActivityModule { + + @Provides + fun provideChips(activity: ContactsActivity): HashMap { + return activity.intent.extras?.getSerializable(ContactsActivity.ChipsKey) + ?.let { serializable -> serializable as? HashMap } + ?: hashMapOf() + } + + @Provides + @IntoMap + @ViewModelKey(ContactsViewModel::class) + fun provideContactsViewModel(viewModel: ContactsViewModel): ViewModel = viewModel + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsContract.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsContract.kt new file mode 100644 index 00000000..09628b38 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsContract.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import com.moez.QKSMS.common.base.QkView +import com.moez.QKSMS.extensions.Optional +import com.moez.QKSMS.feature.compose.editing.ComposeItem +import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction +import io.reactivex.Observable +import io.reactivex.subjects.Subject + +interface ContactsContract : QkView { + + val queryChangedIntent: Observable + val queryBackspaceIntent: Observable<*> + val queryEditorActionIntent: Observable + val composeItemPressedIntent: Subject + val composeItemLongPressedIntent: Subject + val phoneNumberSelectedIntent: Subject> + val phoneNumberActionIntent: Subject + + fun finish(result: HashMap) + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsState.kt new file mode 100644 index 00000000..9d5baad0 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import com.moez.QKSMS.feature.compose.editing.ComposeItem +import com.moez.QKSMS.model.Contact + +data class ContactsState( + val composeItems: List = ArrayList(), + val selectedContact: Contact? = null // For phone number picker +) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsViewModel.kt new file mode 100644 index 00000000..99c94813 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsViewModel.kt @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import android.view.inputmethod.EditorInfo +import com.moez.QKSMS.common.base.QkViewModel +import com.moez.QKSMS.extensions.mapNotNull +import com.moez.QKSMS.extensions.removeAccents +import com.moez.QKSMS.feature.compose.editing.Chip +import com.moez.QKSMS.feature.compose.editing.ComposeItem +import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction +import com.moez.QKSMS.filter.ContactFilter +import com.moez.QKSMS.filter.ContactGroupFilter +import com.moez.QKSMS.interactor.ContactSync +import com.moez.QKSMS.interactor.SetDefaultPhoneNumber +import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.ContactGroup +import com.moez.QKSMS.model.Conversation +import com.moez.QKSMS.model.PhoneNumber +import com.moez.QKSMS.repository.ContactRepository +import com.moez.QKSMS.repository.ConversationRepository +import com.moez.QKSMS.util.PhoneNumberUtils +import com.uber.autodispose.android.lifecycle.scope +import com.uber.autodispose.autoDisposable +import io.reactivex.Observable +import io.reactivex.rxkotlin.Observables +import io.reactivex.rxkotlin.withLatestFrom +import io.reactivex.schedulers.Schedulers +import io.realm.RealmList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.rx2.awaitFirst +import javax.inject.Inject + +class ContactsViewModel @Inject constructor( + serializedChips: HashMap, + syncContacts: ContactSync, + private val contactFilter: ContactFilter, + private val contactGroupFilter: ContactGroupFilter, + private val contactsRepo: ContactRepository, + private val conversationRepo: ConversationRepository, + private val phoneNumberUtils: PhoneNumberUtils, + private val setDefaultPhoneNumber: SetDefaultPhoneNumber +) : QkViewModel(ContactsState()) { + + private val contactGroups: Observable> by lazy { contactsRepo.getUnmanagedContactGroups() } + private val contacts: Observable> by lazy { contactsRepo.getUnmanagedContacts() } + private val recents: Observable> by lazy { conversationRepo.getUnmanagedConversations() } + private val starredContacts: Observable> by lazy { contactsRepo.getUnmanagedContacts(true) } + + private val selectedChips = Observable.just(serializedChips) + .observeOn(Schedulers.io()) + .map { hashmap -> + hashmap.map { (address, lookupKey) -> + Chip(address, lookupKey?.let(contactsRepo::getUnmanagedContact)) + } + } + + init { + syncContacts.execute(Unit) + } + + override fun bindView(view: ContactsContract) { + super.bindView(view) + + // Update the list of contact suggestions based on the query input, while also filtering out any contacts + // that have already been selected + Observables + .combineLatest( + view.queryChangedIntent, recents, starredContacts, contactGroups, contacts, selectedChips + ) { query, recents, starredContacts, contactGroups, contacts, selectedChips -> + val composeItems = mutableListOf() + if (query.isBlank()) { + composeItems += recents + .filter { conversation -> + conversation.recipients.any { recipient -> + selectedChips.none { chip -> + if (recipient.contact == null) { + chip.address == recipient.address + } else { + chip.contact?.lookupKey == recipient.contact?.lookupKey + } + } + } + } + .map(ComposeItem::Recent) + + composeItems += starredContacts + .filter { contact -> selectedChips.none { it.contact?.lookupKey == contact.lookupKey } } + .map(ComposeItem::Starred) + + composeItems += contactGroups + .filter { group -> + group.contacts.any { contact -> + selectedChips.none { chip -> chip.contact?.lookupKey == contact.lookupKey } + } + } + .map(ComposeItem::Group) + + composeItems += contacts + .filter { contact -> selectedChips.none { it.contact?.lookupKey == contact.lookupKey } } + .map(ComposeItem::Person) + } else { + // If the entry is a valid destination, allow it as a recipient + if (phoneNumberUtils.isPossibleNumber(query.toString())) { + val newAddress = phoneNumberUtils.formatNumber(query) + val newContact = Contact(numbers = RealmList(PhoneNumber(address = newAddress))) + composeItems += ComposeItem.New(newContact) + } + + // Strip the accents from the query. This can be an expensive operation, so + // cache the result instead of doing it for each contact + val normalizedQuery = query.removeAccents() + composeItems += starredContacts + .asSequence() + .filter { contact -> selectedChips.none { it.contact?.lookupKey == contact.lookupKey } } + .filter { contact -> contactFilter.filter(contact, normalizedQuery) } + .map(ComposeItem::Starred) + + composeItems += contactGroups + .asSequence() + .filter { group -> + group.contacts.any { contact -> + selectedChips.none { chip -> chip.contact?.lookupKey == contact.lookupKey } + } + } + .filter { group -> contactGroupFilter.filter(group, normalizedQuery) } + .map(ComposeItem::Group) + + composeItems += contacts + .asSequence() + .filter { contact -> selectedChips.none { it.contact?.lookupKey == contact.lookupKey } } + .filter { contact -> contactFilter.filter(contact, normalizedQuery) } + .map(ComposeItem::Person) + } + + composeItems + } + .subscribeOn(Schedulers.computation()) + .autoDisposable(view.scope()) + .subscribe { items -> newState { copy(composeItems = items) } } + + // Listen for ComposeItems being selected, and then send them off to the number picker dialog in case + // the user needs to select a phone number + view.queryEditorActionIntent + .filter { actionId -> actionId == EditorInfo.IME_ACTION_DONE } + .withLatestFrom(state) { _, state -> state } + .mapNotNull { state -> state.composeItems.firstOrNull() } + .mergeWith(view.composeItemPressedIntent) + .map { composeItem -> composeItem to false } + .mergeWith(view.composeItemLongPressedIntent.map { composeItem -> composeItem to true }) + .observeOn(Schedulers.io()) + .autoDisposable(view.scope()) + .subscribe { (composeItem, force) -> + val contacts = composeItem.getContacts() + val newChips = contacts.map { contact -> + if (contact.numbers.size == 1 || contact.getDefaultNumber() != null && !force) { + val number = contact.getDefaultNumber() ?: contact.numbers[0]!! + Chip(number.address, contact) + } else { + runBlocking { + newState { copy(selectedContact = contact) } + val action = view.phoneNumberActionIntent.awaitFirst() + newState { copy(selectedContact = null) } + val numberId = view.phoneNumberSelectedIntent.awaitFirst().value + val number = contact.numbers.find { number -> number.id == numberId } + + if (action == PhoneNumberAction.CANCEL || number == null) { + return@runBlocking null + } + + if (action == PhoneNumberAction.ALWAYS) { + val params = SetDefaultPhoneNumber.Params(contact.lookupKey, number.id) + setDefaultPhoneNumber.execute(params) + } + + Chip(number.address, contact) + } ?: return@subscribe + } + } + + view.finish(HashMap(newChips.associate { chip -> chip.address to chip.contact?.lookupKey })) + } + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/injection/android/ActivityBuilderModule.kt b/presentation/src/main/java/com/moez/QKSMS/injection/android/ActivityBuilderModule.kt index 44deebee..80834509 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/android/ActivityBuilderModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/android/ActivityBuilderModule.kt @@ -22,6 +22,8 @@ import com.moez.QKSMS.feature.backup.BackupActivity import com.moez.QKSMS.feature.blocking.BlockingActivity import com.moez.QKSMS.feature.compose.ComposeActivity import com.moez.QKSMS.feature.compose.ComposeActivityModule +import com.moez.QKSMS.feature.contacts.ContactsActivity +import com.moez.QKSMS.feature.contacts.ContactsActivityModule import com.moez.QKSMS.feature.conversationinfo.ConversationInfoActivity import com.moez.QKSMS.feature.gallery.GalleryActivity import com.moez.QKSMS.feature.gallery.GalleryActivityModule @@ -59,6 +61,10 @@ abstract class ActivityBuilderModule { @ContributesAndroidInjector(modules = [ComposeActivityModule::class]) abstract fun bindComposeActivity(): ComposeActivity + @ActivityScope + @ContributesAndroidInjector(modules = [ContactsActivityModule::class]) + abstract fun bindContactsActivity(): ContactsActivity + @ActivityScope @ContributesAndroidInjector(modules = []) abstract fun bindConversationInfoActivity(): ConversationInfoActivity diff --git a/presentation/src/main/res/layout/compose_activity.xml b/presentation/src/main/res/layout/compose_activity.xml index 16207e2b..3a85a138 100644 --- a/presentation/src/main/res/layout/compose_activity.xml +++ b/presentation/src/main/res/layout/compose_activity.xml @@ -54,7 +54,7 @@ android:text="@string/compose_messages_empty" android:textColor="?android:attr/textColorTertiary" android:visibility="gone" - app:layout_constraintTop_toBottomOf="@id/toolbar" + app:layout_constraintTop_toBottomOf="@id/sendAsGroupBackground" app:textSize="secondary" /> - - + app:constraint_referenced_ids="composeBackground,messageBackground,attachments,attach,message,counter,send" /> + app:constraint_referenced_ids="scheduledTitle,scheduledTime,scheduledCancel,scheduledSeparator" /> - - @@ -411,8 +378,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="gone" - app:constraint_referenced_ids="contact, contactLabel, schedule, scheduleLabel, gallery, galleryLabel, camera, - cameraLabel, attachingBackground" /> + app:constraint_referenced_ids="contact,contactLabel,schedule,scheduleLabel,gallery,galleryLabel,camera,cameraLabel,attachingBackground" /> + + + + + + + + + + + + + + + +