diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce76de39a8dfd5d9a6dcffa07a3b37de2721bd34..14daa9aa93efc2020a96f0e7ded0931840699bf0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,9 +56,18 @@ android:theme="@style/SplashTheme"> <intent-filter> <action android:name="android.intent.action.MAIN" /> - <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> + <intent-filter android:autoVerify="true"> + <category android:name="android.intent.category.BROWSABLE" /> + <category android:name="android.intent.category.DEFAULT" /> + <action android:name="android.intent.action.VIEW" /> + <data + android:scheme="https" + android:host="elixxir.io" + android:pathPrefix="/connect" + /> + </intent-filter> </activity> <activity android:name=".ui.intro.splash.SplashScreenLoadingActivity" @@ -84,9 +93,7 @@ android:launchMode="singleTask"> <intent-filter> <data android:scheme="db-${dropboxKey}" /> - <action android:name="android.intent.action.VIEW" /> - <category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> diff --git a/app/src/main/java/io/xxlabs/messenger/data/datatype/RequestStatus.kt b/app/src/main/java/io/xxlabs/messenger/data/datatype/RequestStatus.kt index 45fc38d723c61ddd7425f49d9ee6465e286e5893..617601e34341ea387629bbd3cec3cb4c940ec06d 100644 --- a/app/src/main/java/io/xxlabs/messenger/data/datatype/RequestStatus.kt +++ b/app/src/main/java/io/xxlabs/messenger/data/datatype/RequestStatus.kt @@ -13,7 +13,8 @@ enum class RequestStatus(val value: Int) { RESENT(7), SENDING(10), DELETING(11), - HIDDEN(12); + HIDDEN(12), + SEARCH(99); companion object { fun from(value: Int) = values().first { it.value == value } diff --git a/app/src/main/java/io/xxlabs/messenger/data/room/dao/ContactsDao.kt b/app/src/main/java/io/xxlabs/messenger/data/room/dao/ContactsDao.kt index 7372cde628632a85aaa673cee0ca08e06ba30716..b3ab9ba44e9af59c52ccbf075551e030ea1efc19 100644 --- a/app/src/main/java/io/xxlabs/messenger/data/room/dao/ContactsDao.kt +++ b/app/src/main/java/io/xxlabs/messenger/data/room/dao/ContactsDao.kt @@ -43,9 +43,6 @@ interface ContactsDao { @Query("SELECT * FROM Contacts WHERE status = :status") fun getAllContactsWithStatusLive(status: Int): LiveData<List<ContactData>> - @Query("SELECT * FROM Contacts WHERE username LIKE :username ORDER BY username") - fun queryAllContactsUsername(username: String): Single<List<ContactData>> - @Query("SELECT * FROM Contacts WHERE username = :username LIMIT 1") fun queryContactByUsername(username: String): Maybe<ContactData> diff --git a/app/src/main/java/io/xxlabs/messenger/data/room/model/Contact.kt b/app/src/main/java/io/xxlabs/messenger/data/room/model/Contact.kt index 57ba12e58c2cfa4f3c65655edc9b1aef8c03bf19..45fb236e55c34b0cc41ea1920e756dd75c2f5745 100644 --- a/app/src/main/java/io/xxlabs/messenger/data/room/model/Contact.kt +++ b/app/src/main/java/io/xxlabs/messenger/data/room/model/Contact.kt @@ -32,8 +32,7 @@ fun Contact.formattedEmail(): String? = else null fun Contact.formattedPhone(flagEmoji: Boolean = false): String? = - if (phone.isNotBlank()) Country.toFormattedNumber(phone, flagEmoji) - else null + phone.ifBlank { null } suspend fun Contact.resolveBitmap(): Bitmap? = withContext(Dispatchers.IO) { BitmapResolver.getBitmap(photo) diff --git a/app/src/main/java/io/xxlabs/messenger/di/modules/FragmentMainBuildersModule.kt b/app/src/main/java/io/xxlabs/messenger/di/modules/FragmentMainBuildersModule.kt index 51204ffd73769bfe80c0e0723542489a2bdfbcd0..a59f54ebd354cb01967a20d48484c7d9868c0b6e 100644 --- a/app/src/main/java/io/xxlabs/messenger/di/modules/FragmentMainBuildersModule.kt +++ b/app/src/main/java/io/xxlabs/messenger/di/modules/FragmentMainBuildersModule.kt @@ -17,6 +17,7 @@ import io.xxlabs.messenger.requests.ui.list.ReceivedRequestsFragment import io.xxlabs.messenger.requests.ui.list.SentRequestsFragment import io.xxlabs.messenger.requests.ui.nickname.SaveNicknameDialog import io.xxlabs.messenger.requests.ui.send.SendRequestDialog +import io.xxlabs.messenger.search.* import io.xxlabs.messenger.ui.main.chat.PrivateMessagesFragment import io.xxlabs.messenger.ui.main.chats.ChatsFragment import io.xxlabs.messenger.ui.main.contacts.list.ContactListFragment @@ -134,4 +135,22 @@ abstract class FragmentMainBuildersModule { @ContributesAndroidInjector abstract fun contributeInvitationAcceptedDialog(): InvitationAcceptedDialog + + @ContributesAndroidInjector + abstract fun contributeUserSearchFragment(): UserSearchFragment + + @ContributesAndroidInjector + abstract fun contributeFactSearchFragment(): FactSearchFragment + + @ContributesAndroidInjector + abstract fun contributeUsernameSearchFragment(): UsernameSearchFragment + + @ContributesAndroidInjector + abstract fun contributeEmailSearchFragment(): EmailSearchFragment + + @ContributesAndroidInjector + abstract fun contributePhoneSearchFragment(): PhoneSearchFragment + + @ContributesAndroidInjector + abstract fun contributeQrSearchFragment(): QrSearchFragment } \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/di/modules/ViewModelModule.kt b/app/src/main/java/io/xxlabs/messenger/di/modules/ViewModelModule.kt index b30ff23dbfcea15296d7b079ce648e2a0988e071..bd58694cb27bdf8e574c7b347a436f0f57b6098e 100644 --- a/app/src/main/java/io/xxlabs/messenger/di/modules/ViewModelModule.kt +++ b/app/src/main/java/io/xxlabs/messenger/di/modules/ViewModelModule.kt @@ -8,6 +8,7 @@ import dagger.multibindings.IntoMap import io.xxlabs.messenger.di.utils.DaggerViewModelFactory import io.xxlabs.messenger.di.utils.ViewModelKey import io.xxlabs.messenger.requests.ui.RequestsViewModel +import io.xxlabs.messenger.search.UserSearchViewModel import io.xxlabs.messenger.ui.base.ContactDetailsViewModel import io.xxlabs.messenger.ui.global.ContactsViewModel import io.xxlabs.messenger.ui.global.NetworkViewModel @@ -97,6 +98,11 @@ abstract class ViewModelModule { @ViewModelKey(ConnectionsViewModel::class) abstract fun bindConnectionsViewModel(connectionsViewModel: ConnectionsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(UserSearchViewModel::class) + abstract fun bindUserSearchViewModel(userSearchViewModel: UserSearchViewModel): ViewModel + @Binds abstract fun bindViewModelFactory(factory: DaggerViewModelFactory): ViewModelProvider.Factory } \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/notifications/MessagingService.kt b/app/src/main/java/io/xxlabs/messenger/notifications/MessagingService.kt index c098e3e84d90ae85339ec8be3a8bd2c70488e355..8cbd1f143de2593be126e3a6c2574ad5c1697fc9 100644 --- a/app/src/main/java/io/xxlabs/messenger/notifications/MessagingService.kt +++ b/app/src/main/java/io/xxlabs/messenger/notifications/MessagingService.kt @@ -5,7 +5,6 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent -import android.media.RingtoneManager.isDefault import android.os.Build import android.os.Bundle import android.os.PowerManager @@ -28,7 +27,7 @@ import io.xxlabs.messenger.requests.ui.RequestsFragment import io.xxlabs.messenger.support.util.value import io.xxlabs.messenger.ui.intro.splash.SplashScreenPlaceholderActivity import io.xxlabs.messenger.ui.main.MainActivity -import io.xxlabs.messenger.ui.main.MainActivity.Companion.INTENT_DEEP_LINK_BUNDLE +import io.xxlabs.messenger.ui.main.MainActivity.Companion.INTENT_NOTIFICATION_CLICK import io.xxlabs.messenger.ui.main.MainActivity.Companion.INTENT_GROUP_CHAT import io.xxlabs.messenger.ui.main.MainActivity.Companion.INTENT_PRIVATE_CHAT import io.xxlabs.messenger.ui.main.MainActivity.Companion.INTENT_REQUEST @@ -196,7 +195,7 @@ class MessagingService : FirebaseMessagingService(), HasAndroidInjector { } } } - intent.putExtra(INTENT_DEEP_LINK_BUNDLE, deepLinkBundle) + intent.putExtra(INTENT_NOTIFICATION_CLICK, deepLinkBundle) return intent } diff --git a/app/src/main/java/io/xxlabs/messenger/repository/client/ClientRepository.kt b/app/src/main/java/io/xxlabs/messenger/repository/client/ClientRepository.kt index 4fb123e5e9991d3937cb78b0a51f3ae0b638c9d0..5d3d5c48905e602ac92ca6f627cfcdc43a754c38 100644 --- a/app/src/main/java/io/xxlabs/messenger/repository/client/ClientRepository.kt +++ b/app/src/main/java/io/xxlabs/messenger/repository/client/ClientRepository.kt @@ -38,6 +38,7 @@ import io.xxlabs.messenger.repository.DaoRepository import io.xxlabs.messenger.repository.PreferencesRepository import io.xxlabs.messenger.repository.base.BasePreferences import io.xxlabs.messenger.repository.base.BaseRepository +import io.xxlabs.messenger.repository.client.NodeErrorException.Companion.isNodeError import io.xxlabs.messenger.support.appContext import io.xxlabs.messenger.support.extensions.fromBase64toByteArray import io.xxlabs.messenger.support.extensions.toBase64String @@ -953,7 +954,13 @@ class ClientRepository @Inject constructor( override fun areNodesReady(): Boolean = recursiveAreNodesReady() private fun recursiveAreNodesReady(retries: Int = 0): Boolean { - val status = clientWrapper.getNodeRegistrationStatus() + val status = try { + clientWrapper.getNodeRegistrationStatus() + } catch (e: Exception) { + if (e.isNodeError()) return false + else throw e + } + val rate: Double = ((status.first.toDouble() / status.second)) Timber.v("[NODE REGISTRATION STATUS] Registration rate: $rate") @@ -1054,4 +1061,10 @@ class ClientRepository @Inject constructor( } -class NodeErrorException : Exception() \ No newline at end of file +class NodeErrorException : Exception() { + companion object { + fun Exception.isNodeError(): Boolean = + message?.contains("network is not healthy", false) + ?: false + } +} \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/requests/ui/RequestsFragment.kt b/app/src/main/java/io/xxlabs/messenger/requests/ui/RequestsFragment.kt index 2786bc8e249e444f983fd008ae1ba47287cbb90c..5f5cd42142c5552586d305ad5461fc1f3a361d06 100644 --- a/app/src/main/java/io/xxlabs/messenger/requests/ui/RequestsFragment.kt +++ b/app/src/main/java/io/xxlabs/messenger/requests/ui/RequestsFragment.kt @@ -48,16 +48,15 @@ import kotlinx.coroutines.launch import java.lang.Exception import javax.inject.Inject -class RequestsFragment : BaseFragment() { +open class RequestsFragment : BaseFragment() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - private val requestsViewModel: RequestsViewModel by viewModels { viewModelFactory } + protected val requestsViewModel: RequestsViewModel by viewModels { viewModelFactory } - private lateinit var navController: NavController - private lateinit var stateAdapter: ViewPagerFragmentStateAdapter - - private lateinit var toastHandler : CustomToastActivity + protected open val navController: NavController by lazy { findNavController() } + protected lateinit var stateAdapter: ViewPagerFragmentStateAdapter + protected lateinit var toastHandler : CustomToastActivity override fun onAttach(context: Context) { super.onAttach(context) @@ -78,7 +77,7 @@ class RequestsFragment : BaseFragment() { return inflater.inflate(R.layout.fragment_requests, container, false) } - private fun observeUI() { + protected open fun observeUI() { requestsViewModel.showReceivedRequestDetails.onEach { request -> request?.let { safelyInvoke { showRequestDetails(request) } @@ -140,24 +139,25 @@ class RequestsFragment : BaseFragment() { /** * Prevents crash caused by user closing/navigating away when a dialog is about to display. */ - private fun safelyInvoke(block: () -> Unit) { - if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + protected open fun safelyInvoke(block: () -> Unit) { + if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { block.invoke() } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - navController = findNavController() - - initComponents(view) + initToolbar() + initViewPager(view) } - fun initComponents(root: View) { + protected open fun initToolbar() { toolbarGeneric.setInsets(topMask = WindowInsetsCompat.Type.systemBars()) toolbarGenericTitle.text = "Requests" bindListeners() + } + protected open fun initViewPager(root: View) { root.apply { setupViewPager(requestsViewPager) TabLayoutMediator(requestsAppBarTabs, requestsViewPager) { tab, position -> @@ -180,7 +180,7 @@ class RequestsFragment : BaseFragment() { } } - private fun setupViewPager(viewPager: ViewPager2) { + protected open fun setupViewPager(viewPager: ViewPager2) { stateAdapter = ViewPagerFragmentStateAdapter(childFragmentManager, lifecycle) stateAdapter.addFragment( ReceivedRequestsFragment(), @@ -205,7 +205,7 @@ class RequestsFragment : BaseFragment() { viewPager.setCurrentItem(selectedTab, false) } - private fun getTabIcon(resourceId: Int): Drawable? { + protected fun getTabIcon(resourceId: Int): Drawable? { return try { ResourcesCompat.getDrawable(resources, resourceId, null) } catch (e: Exception) { @@ -244,7 +244,7 @@ class RequestsFragment : BaseFragment() { .show(childFragmentManager, null) } - private fun showCustomToast(ui: ToastUI) { + protected fun showCustomToast(ui: ToastUI) { toastHandler.showCustomToast(ui) } diff --git a/app/src/main/java/io/xxlabs/messenger/requests/ui/RequestsViewModel.kt b/app/src/main/java/io/xxlabs/messenger/requests/ui/RequestsViewModel.kt index 6dc8f07e4d12284f15ab2f57834968030767d469..c3234c566f0b253c87aaded741c178d48d333a85 100644 --- a/app/src/main/java/io/xxlabs/messenger/requests/ui/RequestsViewModel.kt +++ b/app/src/main/java/io/xxlabs/messenger/requests/ui/RequestsViewModel.kt @@ -89,6 +89,9 @@ class RequestsViewModel @Inject constructor( val sendContactRequest: StateFlow<ContactData?> by ::_sendContactRequest private val _sendContactRequest = MutableStateFlow<ContactData?>(null) + val showContactRequestDialog: LiveData<ContactData?> by ::_showContactRequestDialog + private val _showContactRequestDialog = MutableLiveData<ContactData?>(null) + val showCreateNickname: StateFlow<OutgoingRequest?> by ::_showCreateNickname private val _showCreateNickname = MutableStateFlow<OutgoingRequest?>(null) @@ -344,12 +347,28 @@ class RequestsViewModel @Inject constructor( when (request.request.requestStatus) { VERIFYING -> showVerifyingInfo() VERIFIED, HIDDEN -> showDetails(request) + ACCEPTED -> { + (request.request as? ContactRequest)?.model?.let { contact -> + sendMessage(contact) + } + } + SEARCH -> { + (request.request as? ContactRequest)?.model?.let { user -> + _showContactRequestDialog.value = user as ContactData + } + } } } + fun onSendRequestDialogShown() { + _showContactRequestDialog.value = null + } + private fun showDetails(item: RequestItem) { when (item) { + is ContactRequestSearchResultItem -> showRequestDialog(item.contactRequest) is ContactRequestItem -> showRequestDialog(item.contactRequest) + is SearchResultItem -> showRequestDialog(item.contactRequest) is GroupInviteItem -> showInvitationDialog(item.invite) } } @@ -414,6 +433,7 @@ class RequestsViewModel @Inject constructor( private fun resendRequest(item: RequestItem) { when (item) { is ContactRequestItem -> requestsDataSource.send(item.request as ContactRequest) + is ContactRequestSearchResultItem -> requestsDataSource.send(item.request as ContactRequest) is GroupInviteItem -> invitationsDataSource.send(item.request as GroupInvitation) } onResend(item) diff --git a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItem.kt b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItem.kt index cee801a26b8ac7020688404802d2642be9c7a9d7..62f2f5d5a804ab8d2afeac41a0f741ecbf642ae5 100644 --- a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItem.kt +++ b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItem.kt @@ -4,6 +4,7 @@ import android.graphics.Bitmap import androidx.annotation.IdRes import io.xxlabs.messenger.R import io.xxlabs.messenger.data.datatype.RequestStatus.* +import io.xxlabs.messenger.data.room.model.ContactData import io.xxlabs.messenger.data.room.model.formattedEmail import io.xxlabs.messenger.data.room.model.formattedPhone import io.xxlabs.messenger.requests.model.ContactRequest @@ -20,7 +21,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { abstract val subtitle: String? abstract val details: String? - val actionLabel: String? = + open val actionLabel: String? = when (request.requestStatus) { VERIFYING -> appContext().getString(R.string.request_item_action_verifying) VERIFICATION_FAIL -> appContext().getString(R.string.request_item_action_failed_verification) @@ -29,7 +30,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { else -> null } - val actionIcon: Int? = + open val actionIcon: Int? = when (request.requestStatus) { VERIFICATION_FAIL -> R.drawable.ic_info_outline_24dp SEND_FAIL, SENT -> R.drawable.ic_retry @@ -37,7 +38,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { else -> null } - val actionIconColor: Int? = + open val actionIconColor: Int? = when (request.requestStatus) { VERIFICATION_FAIL -> R.color.accent_danger SEND_FAIL, SENT-> R.color.brand_default @@ -46,7 +47,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { } @IdRes - val actionTextStyle: Int? = + open val actionTextStyle: Int? = when (request.requestStatus) { VERIFYING -> R.style.request_item_verifying VERIFICATION_FAIL -> R.style.request_item_error @@ -58,21 +59,39 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { data class ContactRequestItem( val contactRequest: ContactRequest, - val photo: Bitmap?, + val photo: Bitmap? = null, ) : RequestItem(contactRequest) { override val subtitle: String? = null override val details: String? = contactRequest.getContactInfo() override val itemPhoto: Bitmap? = photo override val itemInitials: String = contactRequest.model.initials override val itemIconRes: Int? = null +} - private fun ContactRequest.getContactInfo(): String? = - with ("${model.formattedEmail() ?: ""}\n${model.formattedPhone() ?: ""}") { - when { - isNullOrBlank() -> null - else -> trim() - } +private fun ContactRequest.getContactInfo(): String? = + with ("${model.formattedEmail() ?: ""}\n${model.formattedPhone() ?: ""}") { + when { + isNullOrBlank() -> null + else -> trim() } + } + +data class ContactRequestSearchResultItem( + val contactRequest: ContactRequest, + val photo: Bitmap? = null, + val statusText: String = "Request pending", + val statusTextColor: Int = R.color.neutral_weak, + val actionVisible: Boolean = true, + override val actionIcon: Int = R.drawable.ic_retry, + override val actionIconColor: Int = R.color.brand_default, + override val actionTextStyle: Int = R.style.request_item_retry, + override val actionLabel: String = if (actionVisible) appContext().getString(R.string.request_item_action_retry) else "" +) : RequestItem(contactRequest) { + override val subtitle: String = statusText + override val details: String? = null + override val itemPhoto: Bitmap? = photo + override val itemInitials: String = contactRequest.model.initials + override val itemIconRes: Int? = null } data class GroupInviteItem( @@ -107,4 +126,38 @@ data class HiddenRequestToggleItem( override val itemInitials: String? = null override val subtitle: String? = null override val details: String? = null +} + +data class AcceptedConnectionItem( + val contactRequest: ContactRequest, + val photo: Bitmap? = null +) : RequestItem(contactRequest) { + override val subtitle: String? = null + override val details: String? = contactRequest.getContactInfo() + override val itemPhoto: Bitmap? = photo + override val itemInitials: String = contactRequest.model.initials + override val itemIconRes: Int? = null +} + +data class SearchResultItem( + val contactRequest: ContactRequest, + val photo: Bitmap? = null +) : RequestItem(contactRequest) { + override val subtitle: String? = null + override val details: String? = contactRequest.getContactInfo() + override val itemPhoto: Bitmap? = photo + override val itemInitials: String = contactRequest.model.initials + override val itemIconRes: Int? = null +} + +data class ConnectionsDividerItem( + val placeholder: NullRequest = NullRequest(), + val text: String = "Local results" +) : RequestItem(placeholder) { + override val title = text + override val itemPhoto: Bitmap? = null + override val itemIconRes: Int? = null + override val itemInitials: String? = null + override val subtitle: String? = null + override val details: String? = null } \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItemViewHolder.kt b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItemViewHolder.kt index aded82f79dacdcba9ff4328b3abf276efe3e8ba8..c6fcb9191c8692f73adb18d2ac220236f29b98bc 100644 --- a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItemViewHolder.kt +++ b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItemViewHolder.kt @@ -9,6 +9,8 @@ import io.xxlabs.messenger.R import io.xxlabs.messenger.databinding.ListItemEmptyPlaceholderBinding import io.xxlabs.messenger.databinding.ListItemHiddenRequestsToggleBinding import io.xxlabs.messenger.databinding.ListItemRequestBinding +import io.xxlabs.messenger.databinding.ListItemRequestSearchResultBinding +import io.xxlabs.messenger.databinding.ListItemSectionDividerBinding import timber.log.Timber import java.io.InvalidObjectException @@ -23,6 +25,7 @@ class RequestViewHolder( override fun onBind(ui: RequestItem, listener: RequestItemListener) { binding.ui = ui binding.listener = listener + listener.markAsSeen(ui) } companion object { @@ -37,6 +40,53 @@ class RequestViewHolder( } } +/** + * For Connection Requests being shown as search results on the Search screen. + */ +class RequestSearchResultViewHolder( + private val binding: ListItemRequestSearchResultBinding +) : RequestItemViewHolder(binding.root) { + + override fun onBind(ui: RequestItem, listener: RequestItemListener) { + binding.ui = ui as ContactRequestSearchResultItem + binding.listener = listener + listener.markAsSeen(ui) + } + + companion object { + fun create(parent: ViewGroup): RequestSearchResultViewHolder { + val binding = ListItemRequestSearchResultBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return RequestSearchResultViewHolder(binding) + } + } +} + +class ConnectionViewHolder( + private val binding: ListItemRequestBinding +) : RequestItemViewHolder(binding.root) { + + override fun onBind(ui: RequestItem, listener: RequestItemListener) { + binding.ui = ui + binding.listener = listener + binding.requestTimestamp.visibility = View.GONE + } + + companion object { + fun create(parent: ViewGroup): ConnectionViewHolder { + val binding = ListItemRequestBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ConnectionViewHolder(binding) + } + } +} + class Placeholder( private val binding: ListItemEmptyPlaceholderBinding ) : RequestItemViewHolder(binding.root) { @@ -77,6 +127,26 @@ class HiddenRequestToggle( } } +class ConnectionsSectionDivider( + private val binding: ListItemSectionDividerBinding +) : RequestItemViewHolder(binding.root) { + + override fun onBind(ui: RequestItem, listener: RequestItemListener) { + binding.ui = ui + } + + companion object { + fun create(parent: ViewGroup): ConnectionsSectionDivider { + val binding = ListItemSectionDividerBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ConnectionsSectionDivider(binding) + } + } +} + /** * Displays an invisible ViewHolder and logs the event to prevent * locking the screen in a permanent crash state. diff --git a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestsAdapter.kt b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestsAdapter.kt index 237fbe265f672d0f7544bfb621165ad725a2ab40..106b94c558e11ff24bbd76d41b79c4a7cfbd73da 100644 --- a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestsAdapter.kt +++ b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestsAdapter.kt @@ -4,6 +4,7 @@ import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import io.xxlabs.messenger.requests.ui.list.adapter.RequestsAdapter.ViewType.* +import io.xxlabs.messenger.support.extensions.toBase64String class RequestsAdapter( private val listener: RequestItemListener @@ -11,9 +12,12 @@ class RequestsAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RequestItemViewHolder { return when (ViewType.from(viewType)) { + CONNECTION, UD_SEARCH_RESULT -> ConnectionViewHolder.create(parent) REQUEST, INVITE -> RequestViewHolder.create(parent) + REQUEST_SEARCH_RESULT -> RequestSearchResultViewHolder.create(parent) PLACEHOLDER -> Placeholder.create(parent) SWITCH -> HiddenRequestToggle.create(parent) + DIVIDER -> ConnectionsSectionDivider.create(parent) OTHER -> InvalidViewType.create(parent) } } @@ -21,7 +25,6 @@ class RequestsAdapter( override fun onBindViewHolder(holder: RequestItemViewHolder, position: Int) { with(currentList[position]) { holder.onBind(this, listener) - listener.markAsSeen(this) } } @@ -33,6 +36,10 @@ class RequestsAdapter( is GroupInviteItem -> INVITE.value is EmptyPlaceholderItem -> PLACEHOLDER.value is HiddenRequestToggleItem -> SWITCH.value + is AcceptedConnectionItem -> CONNECTION.value + is SearchResultItem -> UD_SEARCH_RESULT.value + is ConnectionsDividerItem -> DIVIDER.value + is ContactRequestSearchResultItem -> REQUEST_SEARCH_RESULT.value else -> OTHER.value } status + model @@ -44,7 +51,11 @@ class RequestsAdapter( INVITE(200), PLACEHOLDER(300), SWITCH(400), - OTHER(500); + CONNECTION(500), + UD_SEARCH_RESULT(600), + DIVIDER(700), + REQUEST_SEARCH_RESULT(800), + OTHER(900); companion object { fun from(value: Int): ViewType { @@ -58,7 +69,7 @@ class RequestsAdapter( class RequestsDiffCallback : DiffUtil.ItemCallback<RequestItem>() { override fun areItemsTheSame(oldItem: RequestItem, newItem: RequestItem): Boolean = - oldItem.id.contentEquals(newItem.id) + oldItem.id.toBase64String() == newItem.id.toBase64String() override fun areContentsTheSame(oldItem: RequestItem, newItem: RequestItem): Boolean = oldItem == newItem diff --git a/app/src/main/java/io/xxlabs/messenger/search/FactSearchFragment.kt b/app/src/main/java/io/xxlabs/messenger/search/FactSearchFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..5b5e87be38e3cdadf3c5a8f8dc16d068b0f5f459 --- /dev/null +++ b/app/src/main/java/io/xxlabs/messenger/search/FactSearchFragment.kt @@ -0,0 +1,166 @@ +package io.xxlabs.messenger.search + +import android.os.Bundle +import android.text.Editable +import android.text.SpannableString +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.textfield.TextInputEditText +import io.xxlabs.messenger.databinding.FragmentFactSearchBinding +import io.xxlabs.messenger.di.utils.Injectable +import io.xxlabs.messenger.requests.ui.RequestsViewModel +import io.xxlabs.messenger.requests.ui.list.adapter.RequestItem +import io.xxlabs.messenger.requests.ui.list.adapter.RequestsAdapter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Superclass for Fragments that look up users by Fact (username, phone, etc.) + */ +abstract class FactSearchFragment : Fragment(), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelProvider.Factory + protected val requestsViewModel: RequestsViewModel by viewModels( + ownerProducer = { requireParentFragment() }, + factoryProducer = { viewModelFactory } + ) + protected val searchViewModel: UserSearchViewModel by viewModels( + ownerProducer = { requireParentFragment() }, + factoryProducer = { viewModelFactory } + ) + + protected val resultsAdapter: RequestsAdapter by lazy { + RequestsAdapter(requestsViewModel) + } + protected lateinit var binding: FragmentFactSearchBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentFactSearchBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initComponents() + } + + private fun initComponents() { + binding.searchResultsRV.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = resultsAdapter + } + binding.searchTextInputEditText.apply { + initKeyboardSearchButton() + initFocusListener() + } + binding.ui = getSearchTabUi() + } + + private fun TextInputEditText.initKeyboardSearchButton() { + setOnEditorActionListener { v, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + try { + onSearchClicked(v.text.toString()) + return@setOnEditorActionListener true + } catch (e: Exception) { + return@setOnEditorActionListener false + } + + } else { + return@setOnEditorActionListener false + } + } + } + + private fun TextInputEditText.initFocusListener() { + setOnFocusChangeListener { view, hasFocus -> + if (hasFocus) searchViewModel.onUserInput("") + } + } + + abstract suspend fun getResults(): Flow<List<RequestItem>> + + abstract fun onSearchClicked(query: String?) + + abstract fun getSearchTabUi(): FactSearchUi +} + +class UsernameSearchFragment : FactSearchFragment() { + override suspend fun getResults(): Flow<List<RequestItem>> = + searchViewModel.usernameResults + + override fun onSearchClicked(query: String?) { + lifecycleScope.launch { + searchViewModel.onUsernameSearch(query).collect { results -> + resultsAdapter.submitList(results) + } + } + } + + override fun getSearchTabUi(): FactSearchUi = searchViewModel.usernameSearchUi + + override fun onResume() { + super.onResume() + searchViewModel.invitationFrom.observe(viewLifecycleOwner) { username -> + username?.let { + binding.searchTextInputEditText.setText(username) + onSearchClicked(username) + searchViewModel.onInvitationHandled() + } + } + } +} + +class EmailSearchFragment : FactSearchFragment() { + override suspend fun getResults(): Flow<List<RequestItem>> = + searchViewModel.emailResults + + override fun onSearchClicked(query: String?) { + lifecycleScope.launch { + searchViewModel.onEmailSearch(query).collect { results -> + resultsAdapter.submitList(results) + } + } + } + + override fun getSearchTabUi(): FactSearchUi = searchViewModel.emailSearchUi +} + +class PhoneSearchFragment : FactSearchFragment() { + override suspend fun getResults(): Flow<List<RequestItem>> = + searchViewModel.phoneResults + + override fun onSearchClicked(query: String?) { + lifecycleScope.launch { + searchViewModel.onPhoneSearch(query).collect { results -> + resultsAdapter.submitList(results) + } + } + } + + override fun getSearchTabUi(): FactSearchUi = searchViewModel.phoneSearchUi +} + +class QrSearchFragment : FactSearchFragment() { + override suspend fun getResults(): Flow<List<RequestItem>> = flowOf() + override fun onSearchClicked(query: String?) {} + override fun getSearchTabUi(): FactSearchUi = searchViewModel.qrSearchUi +} \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/search/FactSearchUi.kt b/app/src/main/java/io/xxlabs/messenger/search/FactSearchUi.kt new file mode 100644 index 0000000000000000000000000000000000000000..2de53bd887e6b16eb0dc5a1b2e9c09da06607093 --- /dev/null +++ b/app/src/main/java/io/xxlabs/messenger/search/FactSearchUi.kt @@ -0,0 +1,13 @@ +package io.xxlabs.messenger.search + +import android.text.Editable +import androidx.lifecycle.LiveData + +interface FactSearchUi { + val countryCode: LiveData<String?> + val searchHint: String + val userInputEnabled: LiveData<Boolean> + + fun onCountryClicked() + fun onSearchInput(editable: Editable?) +} \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/search/UdSearchUi.kt b/app/src/main/java/io/xxlabs/messenger/search/UdSearchUi.kt new file mode 100644 index 0000000000000000000000000000000000000000..89dac3e5af32683db800fda1ddd9c66870e4d6bb --- /dev/null +++ b/app/src/main/java/io/xxlabs/messenger/search/UdSearchUi.kt @@ -0,0 +1,25 @@ +package io.xxlabs.messenger.search + +import android.text.Spanned + +interface UdSearchUi { + val callToActionText: Spanned? + val placeholderText: Spanned? + val placeholderVisible: Boolean + val isSearching: Boolean + + fun onPlaceholderClicked() + fun onCancelSearchClicked() +} + +data class SearchUiState( + override val callToActionText: Spanned? = null, + override val placeholderText: Spanned? = null, + override val placeholderVisible: Boolean = false, + override val isSearching: Boolean = false, + private val placeHolderClicked: () -> Unit = {}, + private val cancelClicked: () -> Unit = {} +): UdSearchUi { + override fun onPlaceholderClicked() = placeHolderClicked() + override fun onCancelSearchClicked() = cancelClicked() +} \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/search/UserSearchFragment.kt b/app/src/main/java/io/xxlabs/messenger/search/UserSearchFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..02beb4e7d823fdc168eda107d2d3b3edbaa37e69 --- /dev/null +++ b/app/src/main/java/io/xxlabs/messenger/search/UserSearchFragment.kt @@ -0,0 +1,302 @@ +package io.xxlabs.messenger.search + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayout.OnTabSelectedListener +import com.google.android.material.tabs.TabLayoutMediator +import io.xxlabs.messenger.R +import io.xxlabs.messenger.bindings.wrapper.contact.ContactWrapperBase +import io.xxlabs.messenger.data.room.model.ContactData +import io.xxlabs.messenger.databinding.FragmentUserSearchBinding +import io.xxlabs.messenger.requests.ui.RequestsFragment +import io.xxlabs.messenger.requests.ui.nickname.SaveNicknameDialog +import io.xxlabs.messenger.requests.ui.send.OutgoingRequest +import io.xxlabs.messenger.requests.ui.send.SendRequestDialog +import io.xxlabs.messenger.support.extensions.setInsets +import io.xxlabs.messenger.support.toast.CustomToastActivity +import io.xxlabs.messenger.support.toast.ToastUI +import io.xxlabs.messenger.ui.base.ViewPagerFragmentStateAdapter +import io.xxlabs.messenger.ui.dialog.info.InfoDialog +import io.xxlabs.messenger.ui.dialog.info.InfoDialogUI +import io.xxlabs.messenger.ui.dialog.info.TwoButtonInfoDialog +import io.xxlabs.messenger.ui.dialog.info.TwoButtonInfoDialogUI +import io.xxlabs.messenger.ui.global.ContactsViewModel +import io.xxlabs.messenger.ui.main.countrycode.CountryFullscreenDialog +import io.xxlabs.messenger.ui.main.countrycode.CountrySelectionListener +import kotlinx.android.synthetic.main.component_toolbar_generic.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import java.lang.Exception + +class UserSearchFragment : RequestsFragment() { + + private val contactsViewModel: ContactsViewModel by viewModels { viewModelFactory } + private val searchViewModel: UserSearchViewModel by viewModels { viewModelFactory } + + private lateinit var binding: FragmentUserSearchBinding + override val navController: NavController by lazy { + findNavController() + } + private val invitationUsername: String? by lazy { + UserSearchFragmentArgs.fromBundle(requireArguments()).username + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (context as? CustomToastActivity)?.run { + toastHandler = this + } ?: throw Exception("Activity must implement CustomToastActivity!") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + observeUI() + } + } + binding = FragmentUserSearchBinding.inflate( + inflater, container, false + ) + binding.lifecycleOwner = viewLifecycleOwner + return binding.root + } + + override fun initToolbar() { + binding.userSearchAppBarLayout.apply { + toolbarGeneric.setInsets(topMask = WindowInsetsCompat.Type.systemBars()) + toolbarGenericActionText.visibility = View.VISIBLE + toolbarGenericTitle.text = requireContext().getString(R.string.search_title) + toolbarGenericBackBtn.setOnClickListener { navController.navigateUp() } + } + } + + override fun initViewPager(root: View) { + binding.apply { + setupViewPager(userSearchViewPager) + TabLayoutMediator(userSearchAppBarTabs, userSearchViewPager) { tab, position -> + tab.apply { + text = stateAdapter.getPageTitle(position) + icon = stateAdapter.getIcon(position) + contentDescription = when (position) { + SEARCH_USERNAME -> "search.tab.username" + SEARCH_EMAIL -> "search.tab.email" + SEARCH_PHONE -> "search.tab.phone" + SEARCH_QR -> "search.tab.qr" + else -> "search.tab.invalid" + } + } + }.attach() + + userSearchAppBarTabs.addOnTabSelectedListener( + object : OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + if (searchViewModel.previousTabPosition != SEARCH_QR && tab?.position == SEARCH_QR) { + searchViewModel.previousTabPosition = SEARCH_QR + lifecycleScope.launch { + delay(500) + navigateToQrCode() + } + } else { + searchViewModel.previousTabPosition = tab?.position ?: 0 + } + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { } + override fun onTabReselected(tab: TabLayout.Tab?) { } + } + ) + } + } + + private fun navigateToQrCode() { + val qrCodeScreen = UserSearchFragmentDirections.actionGlobalQrCode() + findNavController().navigate(qrCodeScreen) + } + + override fun setupViewPager(viewPager: ViewPager2) { + stateAdapter = ViewPagerFragmentStateAdapter(childFragmentManager, lifecycle) + stateAdapter.addFragment( + UsernameSearchFragment(), + "Username", + getTabIcon(R.drawable.ic_contact_light) + ) + stateAdapter.addFragment( + EmailSearchFragment(), + "Email", + getTabIcon(R.drawable.ic_mail) + ) + stateAdapter.addFragment( + PhoneSearchFragment(), + "Phone", + getTabIcon(R.drawable.ic_phone) + ) + stateAdapter.addFragment( + QrSearchFragment(), + "QR Code", + getTabIcon(R.drawable.ic_qr_code_label) + ) + + viewPager.adapter = stateAdapter + viewPager.offscreenPageLimit = 3 + + val selectedTab = arguments?.getInt("selectedTab") ?: 0 + viewPager.setCurrentItem(selectedTab, false) + } + + override fun onStart() { + super.onStart() + handleInvitation() + observeUi() + } + + private fun handleInvitation() { + invitationUsername?.let { + searchViewModel.onInvitationReceived(it) + } + } + + private fun observeUi() { + searchViewModel.udSearchUi.observe(viewLifecycleOwner) { state -> + binding.ui = state + } + + super.observeUI() + + requestsViewModel.sendContactRequest.onEach { toUser -> + toUser?.let { + contactsViewModel.updateAndRequestAuthChannel(toUser) + requestsViewModel.onSendRequestHandled() + } + }.launchIn(lifecycleScope) + + requestsViewModel.showCreateNickname.onEach { outgoingRequest -> + outgoingRequest?.let { + showSaveNicknameDialog(it) + requestsViewModel.onShowCreateNicknameHandled() + } + }.launchIn(lifecycleScope) + + searchViewModel.dialogUi.observe(viewLifecycleOwner) { ui -> + ui?.let { + showDialog(ui) + searchViewModel.onDialogShown() + } + } + + searchViewModel.toastUi.observe(viewLifecycleOwner) { ui -> + ui?.let { + showToast(ui) + searchViewModel.onToastShown() + } + } + + searchViewModel.searchInfoDialog.observe(viewLifecycleOwner) { ui -> + ui?.let { + showDialog(ui) + searchViewModel.onInfoDialogShown() + } + } + + searchViewModel.selectCountry.observe(viewLifecycleOwner) { listener -> + listener?.let { + selectCountry(it) + } + } + + searchViewModel.dismissCountries.observe(viewLifecycleOwner) { dismiss -> + if (dismiss) { + dismissCountryList() + searchViewModel.onCountriesDismissed() + } + } + + requestsViewModel.showContactRequestDialog.observe(viewLifecycleOwner) { user -> + user?.let { + showSendRequestDialog(it) + requestsViewModel.onSendRequestDialogShown() + } + } + } + + private var countryList: CountryFullscreenDialog? = null + + private fun selectCountry(listener: CountrySelectionListener) { + safelyInvoke { + countryList = CountryFullscreenDialog.getInstance(listener) + countryList?.show(parentFragmentManager, null) + } + } + + private fun dismissCountryList() { + try { + countryList?.dismiss() + countryList = null + } catch (e: Exception) { + Timber.d("Exception occured when dismissing countries list: ${e.message}") + } + } + + private fun showToast(ui: ToastUI) { + safelyInvoke { + toastHandler.showCustomToast(ui) + } + } + + private fun showDialog(ui: TwoButtonInfoDialogUI) { + safelyInvoke { + TwoButtonInfoDialog + .newInstance(ui) + .show(parentFragmentManager, null) + } + } + + private fun showDialog(ui: InfoDialogUI) { + safelyInvoke { + InfoDialog + .newInstance(ui) + .show(parentFragmentManager, null) + } + } + + private fun showSaveNicknameDialog(outgoingRequest: OutgoingRequest) { + safelyInvoke { + SaveNicknameDialog + .newInstance(outgoingRequest) + .show(childFragmentManager, null) + } + } + + private fun showSendRequestDialog(user: ContactData) { + safelyInvoke { + SendRequestDialog + .newInstance(user) + .show(childFragmentManager, null) + } + } + + companion object { + const val SEARCH_USERNAME = 0 + const val SEARCH_EMAIL = 1 + const val SEARCH_PHONE = 2 + const val SEARCH_QR = 3 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/search/UserSearchViewModel.kt b/app/src/main/java/io/xxlabs/messenger/search/UserSearchViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..47947cb6e230838a98817ee1c3c4e369311a361a --- /dev/null +++ b/app/src/main/java/io/xxlabs/messenger/search/UserSearchViewModel.kt @@ -0,0 +1,724 @@ +package io.xxlabs.messenger.search + +import android.graphics.Bitmap +import android.text.Editable +import android.text.Spannable +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import androidx.lifecycle.* +import io.xxlabs.messenger.R +import io.xxlabs.messenger.bindings.wrapper.contact.ContactWrapperBase +import io.xxlabs.messenger.data.data.Country +import io.xxlabs.messenger.data.datatype.FactType +import io.xxlabs.messenger.data.datatype.RequestStatus +import io.xxlabs.messenger.data.room.model.Contact +import io.xxlabs.messenger.data.room.model.ContactData +import io.xxlabs.messenger.repository.DaoRepository +import io.xxlabs.messenger.repository.PreferencesRepository +import io.xxlabs.messenger.repository.base.BaseRepository +import io.xxlabs.messenger.repository.client.NodeErrorException +import io.xxlabs.messenger.requests.data.contact.ContactRequestData +import io.xxlabs.messenger.requests.data.contact.ContactRequestsRepository +import io.xxlabs.messenger.requests.model.ContactRequest +import io.xxlabs.messenger.requests.ui.list.adapter.* +import io.xxlabs.messenger.support.appContext +import io.xxlabs.messenger.support.toast.ToastUI +import io.xxlabs.messenger.support.util.value +import io.xxlabs.messenger.support.view.BitmapResolver +import io.xxlabs.messenger.ui.dialog.info.InfoDialogUI +import io.xxlabs.messenger.ui.dialog.info.TwoButtonInfoDialogUI +import io.xxlabs.messenger.ui.dialog.info.createInfoDialog +import io.xxlabs.messenger.ui.dialog.info.createTwoButtonDialogUi +import io.xxlabs.messenger.ui.main.countrycode.CountrySelectionListener +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.coroutineContext + +class UserSearchViewModel @Inject constructor( + private val repo: BaseRepository, + private val daoRepo: DaoRepository, + private val preferences: PreferencesRepository, + private val requestsDataSource: ContactRequestsRepository, +): ViewModel(){ + + private val genericSearchError: String by lazy { + appContext().getString(R.string.search_generic_error_message) + } + + var previousTabPosition: Int = UserSearchFragment.SEARCH_USERNAME + + private val initialState: SearchUiState by lazy { + SearchUiState( + callToActionText = callToActionText, + placeholderText = placeholderText, + placeholderVisible = true, + placeHolderClicked = ::onPlaceholderClicked + ) + } + + private val userInputState: SearchUiState by lazy { + SearchUiState() + } + + private val searchRunningState: SearchUiState by lazy { + SearchUiState( + isSearching = true, + cancelClicked = ::onCancelSearchClicked + ) + } + + private val searchCompleteState: SearchUiState by lazy { + SearchUiState(isSearching = false) + } + + private val callToActionText: Spanned by lazy { + val highlight = appContext().getColor(R.color.brand_default) + val cta = appContext().getString(R.string.search_call_to_action) + + val span1 = appContext().getString(R.string.search_call_to_action_span_1) + val span1Start = cta.indexOf(span1, ignoreCase = true) + val span1End = span1Start + span1.length + + val span2 = appContext().getString(R.string.search_call_to_action_span_2) + val span2Start = cta.indexOf(span2, ignoreCase = true) + val span2End = span2Start + span2.length + + SpannableString(cta).apply { + setSpan( + ForegroundColorSpan(highlight), + span1Start, + span1End, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + setSpan( + ForegroundColorSpan(highlight), + span2Start, + span2End, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + private val placeholderText: Spanned by lazy { + val highlight = appContext().getColor(R.color.brand_default) + val text = appContext().getString(R.string.search_placeholder_text) + val span = appContext().getString(R.string.search_placeholder_span) + val startIndex = text.indexOf(span, ignoreCase = true) + + SpannableString(text).apply { + setSpan( + ForegroundColorSpan(highlight), + startIndex, + startIndex + span.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + val searchInfoDialog: LiveData<InfoDialogUI?> by ::_searchInfoDialog + private val _searchInfoDialog = MutableLiveData<InfoDialogUI?>(null) + + private val searchInfoDialogUi: InfoDialogUI by lazy { + createInfoDialog( + title = R.string.search_info_dialog_title, + body = R.string.search_info_dialog_body, + linkTextToUrlMap = mapOf( + appContext().getString(R.string.search_info_dialog_link_text) + to appContext().getString(R.string.search_info_dialog_link_url) + ) + ) + } + + val udSearchUi: LiveData<UdSearchUi> by ::_udSearchUi + private val _udSearchUi = MutableLiveData<UdSearchUi>(initialState) + + val dialogUi: LiveData<TwoButtonInfoDialogUI?> by ::_dialogUi + private val _dialogUi = MutableLiveData<TwoButtonInfoDialogUI?>(null) + + val toastUi: LiveData<ToastUI?> by ::_toastUi + private val _toastUi = MutableLiveData<ToastUI?>(null) + + private val _userInputEnabled = Transformations.map(udSearchUi) { state -> + state != searchRunningState + } + + val usernameSearchUi: FactSearchUi by lazy { + object : FactSearchUi { + override val countryCode: LiveData<String?> = MutableLiveData(null) + override val searchHint: String = "Search by username" + override val userInputEnabled: LiveData<Boolean> by ::_userInputEnabled + override fun onCountryClicked() {} + override fun onSearchInput(editable: Editable?) = onUserInput(editable?.toString()) + } + } + val emailSearchUi: FactSearchUi by lazy { + object : FactSearchUi { + override val countryCode: LiveData<String?> = MutableLiveData(null) + override val searchHint: String = "Search by email address" + override val userInputEnabled: LiveData<Boolean> by ::_userInputEnabled + override fun onCountryClicked() {} + override fun onSearchInput(editable: Editable?) = onUserInput(editable?.toString()) + } + } + val phoneSearchUi: FactSearchUi by lazy { + object : FactSearchUi { + override val countryCode: LiveData<String?> by ::dialCode + override val searchHint: String = "Search by phone number" + override val userInputEnabled: LiveData<Boolean> by ::_userInputEnabled + override fun onCountryClicked() { onCountryCodeClicked() } + override fun onSearchInput(editable: Editable?) = onUserInput(editable?.toString()) + } + } + val qrSearchUi: FactSearchUi by lazy { + object : FactSearchUi { + override val countryCode: LiveData<String?> = MutableLiveData(null) + override val searchHint: String = "Search by QR code" + override val userInputEnabled: LiveData<Boolean> = MutableLiveData(false) + override fun onCountryClicked() { } + override fun onSearchInput(editable: Editable?) = onUserInput(editable?.toString()) + } + } + + private var country: Country = Country.getDefaultCountry() + set(value) { + dialCode.value = "${value.flag} ${value.dialCode}" + field = value + } + private val dialCode = MutableLiveData("${country.flag} ${country.dialCode}") + + val selectCountry: LiveData<CountrySelectionListener?> by ::_selectCountry + private val _selectCountry = MutableLiveData<CountrySelectionListener?>(null) + + private val countryListener: CountrySelectionListener by lazy { + object : CountrySelectionListener { + override val onDismiss = { _selectCountry.value = null } + override fun onItemSelected(country: Country) = onCountrySelected(country) + } + } + + val usernameResults: Flow<List<RequestItem>> by ::_usernameResults + private val _usernameResults = MutableStateFlow<List<RequestItem>>(listOf()) + + val emailResults: Flow<List<RequestItem>> by ::_emailResults + private val _emailResults = MutableStateFlow<List<RequestItem>>(listOf()) + + val phoneResults: Flow<List<RequestItem>> by ::_phoneResults + private val _phoneResults = MutableStateFlow<List<RequestItem>>(listOf()) + + private var searchJob: Job? = null + + val invitationFrom: LiveData<String?> by ::_invitationFrom + private val _invitationFrom = MutableLiveData<String?>(null) + + init { + showNewUserPopups() + } + + private fun showNewUserPopups() { + if (preferences.isFirstTimeNotifications) { + showNotificationDialog() + } + } + + private fun showNotificationDialog() { + _dialogUi.value = createTwoButtonDialogUi( + title = R.string.settings_push_notifications_dialog_title, + body = R.string.settings_push_notifications_dialog_body, + linkTextToUrlMap = null, + positiveClick = ::enablePushNotifications, + negativeClick = null, + onDismiss = ::showCoverMessageDialog + ) + preferences.isFirstTimeNotifications = false + } + + private fun enablePushNotifications() { + viewModelScope.launch { + try { + val notificationToken = enableNotifications() + onNotificationsEnabled(notificationToken) + } catch (e: Exception) { + showError( + e.message ?: "Failed to enable notifications. Please try again in Settings." + ) + } + } + } + + private fun onNotificationsEnabled(token: String) { + token.run { + Timber.d("New token successfully sent! $this") + preferences.areNotificationsOn = true + preferences.currentNotificationsTokenId = this + preferences.notificationsTokenId = this + } + } + + private suspend fun enableNotifications() = repo.registerNotificationsToken().value() + + private fun showCoverMessageDialog() { + _dialogUi.value = createTwoButtonDialogUi( + title = R.string.settings_cover_traffic_title, + body = R.string.settings_cover_traffic_dialog_body, + linkTextToUrlMap = mapOf( + appContext().getString(R.string.settings_cover_traffic_link_text) + to appContext().getString(R.string.settings_cover_traffic_link_url) + ), + positiveClick = { enableDummyTraffic(true) }, + negativeClick = { enableDummyTraffic(false) }, + ) + preferences.isFirstTimeCoverMessages = false + } + + private fun enableDummyTraffic(enabled: Boolean) { + preferences.isCoverTrafficOn = enabled + repo.enableDummyTraffic(enabled) + } + + fun onInvitationReceived(username: String) { + _invitationFrom.value = username + } + + fun onInvitationHandled() { + _invitationFrom.value = null + } + + suspend fun onUsernameSearch(username: String?): Flow<List<RequestItem>> { + _usernameResults.value = listOf() + val factQuery = FactQuery.UsernameQuery(username) + return search(factQuery).cancellable() + } + + suspend fun onEmailSearch(email: String?): Flow<List<RequestItem>> { + _emailResults.value = listOf() + val factQuery = FactQuery.EmailQuery(email) + return search(factQuery).cancellable() + } + + suspend fun onPhoneSearch(phone: String?): Flow<List<RequestItem>> { + _phoneResults.value = listOf() + val factQuery = FactQuery.PhoneQuery(phone + country.countryCode) + return search(factQuery).cancellable() + } + + private suspend fun search(factQuery: FactQuery): Flow<List<RequestItem>> { + // Cancel previous searches, save a reference to this one. + searchJob?.cancel() + searchJob = coroutineContext.job + + if (!isValidQuery(factQuery)) return flowOf(listOf()) + changeStateTo(searchRunningState) + + return combine( + searchUd(factQuery), + allRequests(), + allConnections() + ) { ud, allRequests, allConnections -> + val foundRequests = allRequests.matching(factQuery).toMutableSet() + val foundConnections = allConnections.matching(factQuery).toMutableSet() + + // Add identical request to request results, if not already there. + allRequests.identicalTo(ud.username)?.let { + foundRequests.add(it) + } + + // Add identical connection to connection results, if not already there. + allConnections.identicalTo(ud.username)?.let { + foundConnections.add(it) + } + + val alreadyRequested = ud.username in foundRequests.map { request -> + request.username + } + + val alreadyAdded = ud.username in foundConnections.map { connection -> + connection.username + } + + val nonConnections = + if (alreadyRequested || alreadyAdded) { + // If the UD result's userID match a request's userID, only show the request + foundRequests.toList().sortedBy { it.username } + } else { + // Otherwise show both + listOf(ud) + foundRequests.sortedBy { it.username } + } + + if (nonConnections.isEmpty()) { + // If there's no UD result or Requests, just show the Connections with no divider. + foundConnections.toList().sortedBy { + it.username + }.ifEmpty { + // Show a "no results found" placeholder if there's nothing at all. + noResultsFor(factQuery) + } + } else { + if (foundConnections.isEmpty()) { + // If there's no Connections, show the UD & Request results. + nonConnections + } else { + // Or show the UD results, Requests, a divider, and finally Connections. + nonConnections + .plus(listOf(ConnectionsDividerItem())) + .plus(foundConnections.toList().sortedBy { it.username }) + } + } + } + } + + private val RequestItem.username: String + get() = (request as? ContactRequest)?.model?.username ?: "" + + private suspend fun allRequests(): Flow<List<RequestItem>> = + requestsDataSource.getRequests().map { requestsList -> + requestsList.map { + it.asRequestSearchResult() + } + }.stateIn(viewModelScope) + + private suspend fun allConnections() = flow { + val connectionsList = savedUsers().filter { + it.isConnection() + }.asConnectionsSearchResult() + + emit(connectionsList) + }.stateIn(viewModelScope) + + private fun List<RequestItem>.identicalTo(username: String): RequestItem? = + firstOrNull { it.username == username } + + private fun List<RequestItem>.matching(factQuery: FactQuery): List<RequestItem> { + return when (factQuery.type) { + FactType.USERNAME -> { + filter { + (it.request as? ContactRequest)?.model?.displayName?.contains( + factQuery.fact, + true + ) ?: false + } + } + FactType.EMAIL -> { + filter { + (it.request as? ContactRequest)?.model?.email?.contains( + factQuery.fact, + true + ) ?: false + } + } + FactType.PHONE -> { + filter { + (it.request as? ContactRequest)?.model?.phone?.contains( + factQuery.fact, + true + ) ?: false + } + } + else -> listOf() + } + } + + private fun isValidQuery(factQuery: FactQuery): Boolean { + return with (factQuery.fact) { + if (isNullOrBlank()) { + // Prevent blank text + false + } else { + // Prevent users from searching (and possibly requesting) themselves. + this != repo.getStoredUsername() + && this != repo.getStoredEmail() + && this != repo.getStoredPhone() + } + } + } + + private fun noResultsFor(factQuery: FactQuery): List<RequestItem> = + listOf(noResultPlaceholder(factQuery)) + + private fun noResultPlaceholder(factQuery: FactQuery): RequestItem = + EmptyPlaceholderItem( + text = "There are no users with that ${factQuery.type.name.lowercase()}." + ) + + private fun couldNotCompletePlaceholder(error: String): RequestItem = + EmptyPlaceholderItem(text = error) + + private suspend fun savedUsers(): List<ContactData> = + daoRepo.getAllContacts().value() + + private fun ContactData.isConnection(): Boolean = + RequestStatus.from(status) == RequestStatus.ACCEPTED + + private suspend fun searchConnections(factQuery: FactQuery) = flow { + val results = when (factQuery.type) { + FactType.USERNAME -> { + savedUsers().filter { + it.isConnection() && it.displayName.contains(factQuery.fact, true) + }.asConnectionsSearchResult() + + } + FactType.EMAIL -> { + savedUsers().filter { + it.isConnection() && it.email.contains(factQuery.fact, true) + }.asConnectionsSearchResult() + + } + FactType.PHONE -> { + savedUsers().filter { + it.isConnection() && it.phone.contains(factQuery.fact, true) + }.asConnectionsSearchResult() + } + else -> listOf() + } + emit(results) + }.stateIn(viewModelScope) + + private suspend fun searchRequests(factQuery: FactQuery) = + when (factQuery.type) { + FactType.USERNAME -> { + filterRequests { + it.model.displayName.contains(factQuery.fact, true) + } + } + FactType.EMAIL -> { + filterRequests { + it.model.email.contains(factQuery.fact, true) + } + } + FactType.PHONE -> { + filterRequests { + it.model.phone.contains( + Country.toFormattedNumber(factQuery.fact, false) ?: factQuery.fact + ) + } + } + else -> flow { listOf<RequestItem>() } + }.stateIn(viewModelScope) + + private suspend fun filterRequests(match: (contactRequest: ContactRequest) -> Boolean) = + requestsDataSource.getRequests().map { requestsList -> + requestsList.filter { + match(it) + }.map { + it.asRequestSearchResult() + } + }.flowOn(Dispatchers.IO) + + private suspend fun List<ContactData>.asConnectionsSearchResult(): List<RequestItem> = + map { + val requestData = ContactRequestData(it) + AcceptedConnectionItem( + requestData, + resolveBitmap(it.photo) + ) + } + + private suspend fun ContactRequest.asRequestSearchResult(): RequestItem = + ContactRequestSearchResultItem( + contactRequest = this, + photo = resolveBitmap(model.photo), + statusText = model.statusText(), + statusTextColor = model.statusTextColor(), + actionVisible = model.actionVisible(), + actionIcon = model.actionIcon(), + actionIconColor = model.actionIconColor(), + actionTextStyle = model.actionTextStyle(), + actionLabel = model.actionLabel() + ) + + private fun Contact.statusText(): String { + return when (RequestStatus.from(status)) { + RequestStatus.SENT, + RequestStatus.VERIFIED, + RequestStatus.RESET_SENT, + RequestStatus.RESENT, + RequestStatus.VERIFYING, + RequestStatus.HIDDEN, + RequestStatus.SENDING -> "Request pending" + + RequestStatus.SEND_FAIL, + RequestStatus.CONFIRM_FAIL, + RequestStatus.VERIFICATION_FAIL, + RequestStatus.RESET_FAIL -> "Request failed" + + else -> "" + } + } + + private fun Contact.statusTextColor(): Int { + return when (RequestStatus.from(status)) { + RequestStatus.SEND_FAIL, + RequestStatus.CONFIRM_FAIL, + RequestStatus.VERIFICATION_FAIL, + RequestStatus.RESET_FAIL -> R.color.accent_danger + + else -> R.color.neutral_weak + } + } + + private fun Contact.actionVisible(): Boolean { + return when (RequestStatus.from(status)) { + RequestStatus.VERIFIED, RequestStatus.VERIFYING, RequestStatus.HIDDEN -> false + else -> true + } + } + + private fun Contact.actionIcon(): Int { + return when (RequestStatus.from(status)) { + RequestStatus.RESENT -> R.drawable.ic_check_green + else -> R.drawable.ic_retry + } + } + + private fun Contact.actionIconColor(): Int { + return when (RequestStatus.from(status)) { + RequestStatus.RESENT -> R.color.accent_success + else -> R.color.brand_default + } + } + + private fun Contact.actionTextStyle(): Int { + return when (RequestStatus.from(status)) { + RequestStatus.RESENT -> R.drawable.ic_check_green + else -> R.style.request_item_resent + } + } + + private fun Contact.actionLabel(): String { + return when (RequestStatus.from(status)) { + RequestStatus.RESENT -> appContext().getString(R.string.request_item_action_resent) + else -> appContext().getString(R.string.request_item_action_retry) + } + } + + + private suspend fun resolveBitmap(data: ByteArray?): Bitmap? = withContext(Dispatchers.IO) { + BitmapResolver.getBitmap(data) + } + + private suspend fun searchUd(factQuery: FactQuery) = flow { + val result = try { + val udResult = fetchUser(factQuery) + udResult.second?.let { // Error message + if (it.isNotEmpty()) { + if (!it.contains("no results found", true)) { + showError(it) + } + noResultPlaceholder(factQuery) + } else { // Search result + udResult.first?.asSearchResult() ?: noResultPlaceholder(factQuery) + } + } ?: run { + udResult.first?.asSearchResult() ?: noResultPlaceholder(factQuery) + } + } catch (e: Exception) { + e.message?.let { + showError(genericSearchError) + couldNotCompletePlaceholder(it) + } ?: run { + couldNotCompletePlaceholder(genericSearchError) + } + } + + changeStateTo(searchCompleteState) + + emit(result) + }.stateIn(viewModelScope) + + private suspend fun fetchUser(factQuery: FactQuery): Pair<ContactWrapperBase?, String?> { + return try { + repo.searchUd(factQuery.fact, factQuery.type).value() + } catch (e: NodeErrorException) { + delay(5000) + fetchUser(factQuery) + } + } + + private fun ContactWrapperBase.asSearchResult(): RequestItem { + // ContactWrapperBase -> ContactRequestData + val requestData = ContactRequestData( + ContactData.from(this, RequestStatus.SEARCH) + ) + // ContactRequestData -> RequestItem + return SearchResultItem(requestData) + } + + private fun changeStateTo(ui: UdSearchUi) { + _udSearchUi.postValue(ui) + } + + private fun showError(error: String) { + _toastUi.postValue( + ToastUI.create( + body = error, + leftIcon = R.drawable.ic_alert + ) + ) + } + + fun onToastShown() { + _toastUi.value = null + } + + fun onDialogShown() { + _dialogUi.value = null + } + + private fun onPlaceholderClicked() { + _searchInfoDialog.value = searchInfoDialogUi + } + + fun onInfoDialogShown() { + _searchInfoDialog.value = null + } + + private fun onCancelSearchClicked() { + searchJob?.cancel() + changeStateTo(searchCompleteState) + } + + private fun onCountryCodeClicked() { + _selectCountry.value = countryListener + } + + val dismissCountries: LiveData<Boolean> by ::_dismissCountries + private val _dismissCountries = MutableLiveData(false) + + private fun onCountrySelected(selectedCountry: Country?) { + _dismissCountries.value = true + country = selectedCountry ?: return + } + + fun onCountriesDismissed() { + _dismissCountries.value = false + } + + fun onUserInput(input: String?) { + _udSearchUi.value = + input?.let { + userInputState + } ?: initialState + } +} + +private sealed class FactQuery { + abstract val fact: String + abstract val type: FactType + + class UsernameQuery(query: String?): FactQuery() { + override val fact: String = query ?: "" + override val type: FactType = FactType.USERNAME + } + + class EmailQuery(query: String?): FactQuery() { + override val fact: String = query ?: "" + override val type: FactType = FactType.EMAIL + } + + class PhoneQuery(query: String?): FactQuery() { + override val fact: String = query ?: "" + override val type: FactType = FactType.PHONE + } +} \ No newline at end of file diff --git a/app/src/main/java/io/xxlabs/messenger/ui/dialog/info/InfoDialogLauncher.kt b/app/src/main/java/io/xxlabs/messenger/ui/dialog/info/InfoDialogLauncher.kt index 99f5cd93980aee5bc0d49c8da204a2be72a71618..b680da37a37f6a26b503e78c18b8c8fdbf78949f 100644 --- a/app/src/main/java/io/xxlabs/messenger/ui/dialog/info/InfoDialogLauncher.kt +++ b/app/src/main/java/io/xxlabs/messenger/ui/dialog/info/InfoDialogLauncher.kt @@ -1,6 +1,8 @@ package io.xxlabs.messenger.ui.dialog.info import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import io.xxlabs.messenger.support.appContext /** * Launches an InfoDialog with a neutral button. @@ -31,6 +33,30 @@ fun Fragment.showInfoDialog( .show(requireActivity().supportFragmentManager, null) } +fun ViewModel.createInfoDialog( + title: Int, + body: Int, + linkTextToUrlMap: Map<String, String>? = null +) : InfoDialogUI { + var spans: MutableList<SpanConfig>? = null + linkTextToUrlMap?.apply { + spans = mutableListOf() + for (entry in keys) { + val spanConfig = SpanConfig.create( + entry, + this[entry], + ) + spans?.add(spanConfig) + } + } + + return InfoDialogUI.create( + title = appContext().getString(title), + body = appContext().getString(body), + spans = spans, + ) +} + /** * Launches an InfoDialog with a positive and negative button. */ @@ -68,3 +94,36 @@ fun Fragment.showTwoButtonInfoDialog( TwoButtonInfoDialog.newInstance(twoButtonUI) .show(parentFragmentManager, null) } + +fun ViewModel.createTwoButtonDialogUi( + title: Int, + body: Int, + linkTextToUrlMap: Map<String, String>? = null, + positiveClick: ()-> Unit, + negativeClick: (()-> Unit)? = null, + onDismiss: ()-> Unit = { }, +) : TwoButtonInfoDialogUI { + var spans: MutableList<SpanConfig>? = null + linkTextToUrlMap?.apply { + spans = mutableListOf() + for (entry in keys) { + val spanConfig = SpanConfig.create( + entry, + this[entry], + ) + spans?.add(spanConfig) + } + } + val infoDialogUI = InfoDialogUI.create( + title = appContext().getString(title), + body = appContext().getString(body), + spans = spans, + onDismiss + ) + + return TwoButtonInfoDialogUI.create( + infoDialogUI, + onPositiveClick = positiveClick, + onNegativeClick = negativeClick + ) +} diff --git a/app/src/main/java/io/xxlabs/messenger/ui/intro/splash/SplashScreenPlaceholderActivity.kt b/app/src/main/java/io/xxlabs/messenger/ui/intro/splash/SplashScreenPlaceholderActivity.kt index a1dee2d293d8b04735b91a4cb2f092f80818fa1d..68bc86051046701fa28bb73cc400d609aed2159d 100644 --- a/app/src/main/java/io/xxlabs/messenger/ui/intro/splash/SplashScreenPlaceholderActivity.kt +++ b/app/src/main/java/io/xxlabs/messenger/ui/intro/splash/SplashScreenPlaceholderActivity.kt @@ -19,7 +19,9 @@ import io.xxlabs.messenger.support.isMockVersion import io.xxlabs.messenger.support.util.Utils import io.xxlabs.messenger.ui.base.BaseInjectorActivity import io.xxlabs.messenger.ui.main.MainActivity -import io.xxlabs.messenger.ui.main.MainActivity.Companion.INTENT_DEEP_LINK_BUNDLE +import io.xxlabs.messenger.ui.main.MainActivity.Companion.INTENT_INVITATION +import io.xxlabs.messenger.ui.main.MainActivity.Companion.INTENT_NOTIFICATION_CLICK +import timber.log.Timber import javax.inject.Inject class SplashScreenPlaceholderActivity : BaseInjectorActivity() { @@ -54,13 +56,33 @@ class SplashScreenPlaceholderActivity : BaseInjectorActivity() { } private fun handleIntent(intent: Intent) { + if (Intent.ACTION_VIEW == intent.action) { + // Implicit Intent from an invitation link + intent.data?.getQueryParameter("username")?.let { username -> + invitationIntent(username) + } + } else notificationIntent(intent) + } + + private fun invitationIntent(username: String) { + // Invitations can only be handled if the user has an account. + if (preferencesRepository.name.isNotEmpty()) { + val intent = Intent(this, MainActivity::class.java).apply { + putExtra(INTENT_INVITATION, username) + } + startActivity(intent) + finish() + } + } + + private fun notificationIntent(intent: Intent) { // Pass this intent on to MainActivity. - mainIntent = intent.getBundleExtra(INTENT_DEEP_LINK_BUNDLE)?.let { + mainIntent = intent.getBundleExtra(INTENT_NOTIFICATION_CLICK)?.let { Intent( this@SplashScreenPlaceholderActivity, MainActivity::class.java ).apply { - putExtra(INTENT_DEEP_LINK_BUNDLE, it) + putExtra(INTENT_NOTIFICATION_CLICK, it) } } } diff --git a/app/src/main/java/io/xxlabs/messenger/ui/main/MainActivity.kt b/app/src/main/java/io/xxlabs/messenger/ui/main/MainActivity.kt index 971108cb3d40ebac18b0478ac01c3a324592b9c9..64a04e9412732e12828f0405743dbc5e1966552c 100755 --- a/app/src/main/java/io/xxlabs/messenger/ui/main/MainActivity.kt +++ b/app/src/main/java/io/xxlabs/messenger/ui/main/MainActivity.kt @@ -4,7 +4,6 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.annotation.SuppressLint -import android.app.ProgressDialog.show import android.content.Context import android.content.Intent import android.graphics.BitmapFactory @@ -20,12 +19,12 @@ import androidx.lifecycle.* import androidx.lifecycle.Observer import androidx.navigation.NavController import androidx.navigation.Navigation +import androidx.navigation.findNavController import androidx.navigation.ui.onNavDestinationSelected import com.bumptech.glide.Glide import com.google.android.material.shape.CornerFamily import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_INDEFINITE -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import io.xxlabs.messenger.BuildConfig import io.xxlabs.messenger.NavMainDirections @@ -33,11 +32,9 @@ import io.xxlabs.messenger.R import io.xxlabs.messenger.bindings.wrapper.bindings.BindingsWrapperBindings import io.xxlabs.messenger.data.data.DataRequestState import io.xxlabs.messenger.data.data.SimpleRequestState -import io.xxlabs.messenger.data.datatype.NetworkState import io.xxlabs.messenger.data.room.model.Contact import io.xxlabs.messenger.databinding.ComponentCustomToastBinding import io.xxlabs.messenger.media.MediaProviderActivity -import io.xxlabs.messenger.notifications.MessagingService import io.xxlabs.messenger.support.callback.NetworkWatcher import io.xxlabs.messenger.support.dialog.PopupActionBottomDialog import io.xxlabs.messenger.support.extensions.* @@ -61,7 +58,6 @@ import kotlinx.android.synthetic.main.component_menu.* import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import timber.log.Timber -import java.util.* import javax.inject.Inject private val Bundle.isPrivateMessage: Boolean @@ -168,12 +164,27 @@ class MainActivity : MediaProviderActivity(), SnackBarActivity, CustomToastActiv } private fun handleIntent(intent: Intent) { - intent.getBundleExtra(INTENT_DEEP_LINK_BUNDLE)?.let { - handleDeepLink(it) + intent.getBundleExtra(INTENT_NOTIFICATION_CLICK)?.let { + // PendingIntent from notifications + handleNotification(it) + return } + + intent.getStringExtra(INTENT_INVITATION)?.let { username -> + // Implicit intent from an invitation link + invitationIntent(username) + return + } + } + + private fun invitationIntent(username: String) { + val userSearch = NavMainDirections.actionGlobalConnectionInvitation().apply { + this.username = username + } + mainNavController.navigateSafe(userSearch) } - private fun handleDeepLink(bundle: Bundle) { + private fun handleNotification(bundle: Bundle) { with (bundle) { when { isPrivateMessage -> privateMessageIntent(this) @@ -361,6 +372,37 @@ class MainActivity : MediaProviderActivity(), SnackBarActivity, CustomToastActiv hideMenu() mainNavController.navigateSafe(R.id.action_global_ud_profile) } + + menuShareText?.setOnSingleClickListener { + hideMenu() + sendInvitation() + } + } + + private fun sendInvitation() { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra( + Intent.EXTRA_TEXT, + getString(R.string.share_invitation_message, preferences.name) + ) + type = "text/plain" + } + + val title: String = getString(R.string.share_chooser_title) + val chooser: Intent = Intent.createChooser(sendIntent, title) + + if (sendIntent.resolveActivity(packageManager) != null) { + startActivity(chooser) + } else { + showCustomToast( + ToastUI.create( + body = getString(R.string.share_no_activity_error), + leftIcon = R.drawable.ic_alert, + iconTint = R.color.accent_danger + ) + ) + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -781,10 +823,11 @@ class MainActivity : MediaProviderActivity(), SnackBarActivity, CustomToastActiv } companion object : BaseInstance { - const val INTENT_DEEP_LINK_BUNDLE = "nav_bundle" + const val INTENT_NOTIFICATION_CLICK = "nav_bundle" const val INTENT_PRIVATE_CHAT = "private_message" const val INTENT_GROUP_CHAT = "group_message" const val INTENT_REQUEST = "request" + const val INTENT_INVITATION = "invitation" private var activeInstances = 0 override fun activeInstancesCount(): Int { diff --git a/app/src/main/java/io/xxlabs/messenger/ui/main/ud/search/UdSearchFragment.kt b/app/src/main/java/io/xxlabs/messenger/ui/main/ud/search/UdSearchFragment.kt index 5cb03d40c26e2c484db0e1e22856484fe5ace446..2c2508af2320dbce72242db2d2a021bc933073fd 100644 --- a/app/src/main/java/io/xxlabs/messenger/ui/main/ud/search/UdSearchFragment.kt +++ b/app/src/main/java/io/xxlabs/messenger/ui/main/ud/search/UdSearchFragment.kt @@ -27,7 +27,6 @@ import io.xxlabs.messenger.bindings.wrapper.contact.ContactWrapperBase import io.xxlabs.messenger.data.data.Country import io.xxlabs.messenger.data.data.DataRequestState import io.xxlabs.messenger.data.data.SimpleRequestState -import io.xxlabs.messenger.data.datatype.NetworkState import io.xxlabs.messenger.data.room.model.ContactData import io.xxlabs.messenger.requests.ui.RequestsViewModel import io.xxlabs.messenger.requests.ui.nickname.SaveNicknameDialog @@ -44,7 +43,6 @@ import io.xxlabs.messenger.ui.main.countrycode.CountryFullscreenDialog import io.xxlabs.messenger.ui.main.countrycode.CountrySelectionListener import kotlinx.android.synthetic.main.component_toolbar_generic.* import kotlinx.android.synthetic.main.fragment_private_search.* -import io.xxlabs.messenger.ui.dialog.info.showTwoButtonInfoDialog import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -418,14 +416,6 @@ class UdSearchFragment : BaseFragment() { } private fun watchForChanges() { -// networkViewModel.networkState.observe(viewLifecycleOwner, { networkState -> -// if (networkState != NetworkState.HAS_CONNECTION) { -// snackBar.show() -// } else { -// snackBar.dismiss() -// } -// }) - networkViewModel.userDiscoveryStatus.observe( viewLifecycleOwner, { isUdCompletelyInitialized -> diff --git a/app/src/main/res/drawable/ic_connections.xml b/app/src/main/res/drawable/ic_connections.xml new file mode 100644 index 0000000000000000000000000000000000000000..4079f3f8406a42717da41af33e0947cad5bb0416 --- /dev/null +++ b/app/src/main/res/drawable/ic_connections.xml @@ -0,0 +1,31 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M6,3L18,3A1,1 0,0 1,19 4L19,20A1,1 0,0 1,18 21L6,21A1,1 0,0 1,5 20L5,4A1,1 0,0 1,6 3z" + android:strokeWidth="2" + android:fillColor="#00000000" + android:strokeColor="#B1B5BA"/> + <path + android:pathData="M15,17C15,15.343 13.657,14 12,14C10.343,14 9,15.343 9,17" + android:strokeWidth="2" + android:fillColor="#00000000" + android:strokeColor="#B1B5BA"/> + <path + android:pathData="M12,10m-2,0a2,2 0,1 1,4 0a2,2 0,1 1,-4 0" + android:strokeWidth="2" + android:fillColor="#00000000" + android:strokeColor="#B1B5BA"/> + <path + android:pathData="M3,9L7,9" + android:strokeWidth="2" + android:fillColor="#00000000" + android:strokeColor="#B1B5BA"/> + <path + android:pathData="M3,15L7,15" + android:strokeWidth="2" + android:fillColor="#00000000" + android:strokeColor="#B1B5BA"/> +</vector> diff --git a/app/src/main/res/drawable/ic_dashboard_24px.xml b/app/src/main/res/drawable/ic_dashboard_24px.xml new file mode 100644 index 0000000000000000000000000000000000000000..87968f4aa1dea8e80982856211d6a23aa52b37f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_24px.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="18dp" + android:height="18dp" + android:viewportWidth="18" + android:viewportHeight="18"> + <path + android:pathData="M0,0H8V10H0V0ZM18,0H10V6H18V0ZM6,8V2H2V8H6ZM16,4V2H12V4H16ZM16,10V16H12V10H16ZM6,16V14H2V16H6ZM18,8H10V18H18V8ZM0,12H8V18H0V12Z" + android:fillColor="#B1B5BA" + android:fillType="evenOdd"/> +</vector> diff --git a/app/src/main/res/drawable/ic_qr_squared.xml b/app/src/main/res/drawable/ic_qr_squared.xml index 75c7ec109b1aac8b4287d00c5bd51d9ca373966d..ab0db05f5756950739c6d64c106738efc4cec863 100644 --- a/app/src/main/res/drawable/ic_qr_squared.xml +++ b/app/src/main/res/drawable/ic_qr_squared.xml @@ -5,9 +5,9 @@ android:viewportHeight="40"> <path android:pathData="M8,0L32,0A8,8 0,0 1,40 8L40,32A8,8 0,0 1,32 40L8,40A8,8 0,0 1,0 32L0,8A8,8 0,0 1,8 0z" - android:fillColor="@color/neutral_body"/> + android:fillColor="@color/neutral_active"/> <path android:pathData="M11.875,14.5C11.875,13.1227 12.9977,12 14.375,12H16.625V14H14.375C14.1023,14 13.875,14.2273 13.875,14.5V16.75H11.875V14.5ZM25.625,14H23.375V12H25.625C27.0023,12 28.125,13.1227 28.125,14.5V16.75H26.125V14.5C26.125,14.2273 25.8977,14 25.625,14ZM13.875,25.75V23.5H11.875V25.75C11.875,27.1273 12.9977,28.25 14.375,28.25H16.625V26.25H14.375C14.1023,26.25 13.875,26.0227 13.875,25.75ZM28.125,23.5V25.75C28.125,27.1273 27.0023,28.25 25.625,28.25H23.375V26.25H25.625C25.8977,26.25 26.125,26.0227 26.125,25.75V23.5H28.125ZM11,21.125H29V19.125H11V21.125Z" - android:fillColor="#242424" + android:fillColor="@color/neutral_white" android:fillType="evenOdd"/> </vector> diff --git a/app/src/main/res/drawable/ic_requests.xml b/app/src/main/res/drawable/ic_requests.xml new file mode 100644 index 0000000000000000000000000000000000000000..4d9fbd5eb27bb5a6c590ff2c16a8db26f0228113 --- /dev/null +++ b/app/src/main/res/drawable/ic_requests.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M19.2,19.4H4.8C3.806,19.4 3,18.594 3,17.6V6.722C3.042,5.758 3.836,4.999 4.8,5H19.2C20.194,5 21,5.806 21,6.8V17.6C21,18.594 20.194,19.4 19.2,19.4ZM4.8,8.481V17.6H19.2V8.481L12,13.28L4.8,8.481ZM5.52,6.8L12,11.12L18.48,6.8H5.52Z" + android:fillColor="#B1B5BA"/> +</vector> diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000000000000000000000000000000000000..098c40b4d863e371b67af6a7edc774b729301eb6 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M17.6,18.199V15.498C17.6,15.001 18.003,14.598 18.5,14.598C18.997,14.598 19.4,15.001 19.4,15.498V18.199C19.4,19.192 18.594,20 17.604,20H6.796C5.803,20 5,19.196 5,18.199V15.498C5,15.001 5.403,14.598 5.9,14.598C6.397,14.598 6.8,15.001 6.8,15.498L6.796,18.199L17.6,18.199Z" + android:fillColor="#B1B5BA"/> + <path + android:pathData="M14.109,8.079L13.103,7.072L13.103,15.49C13.103,15.987 12.703,16.391 12.203,16.391C11.706,16.391 11.303,15.988 11.303,15.49L11.303,7.072L10.296,8.079C9.943,8.432 9.375,8.436 9.021,8.081C8.669,7.73 8.671,7.158 9.023,6.805L11.564,4.264C11.742,4.086 11.972,3.999 12.202,3.999C12.433,3.997 12.663,4.085 12.839,4.262C12.841,4.263 15.382,6.805 15.382,6.805C15.735,7.159 15.739,7.727 15.385,8.081C15.033,8.433 14.461,8.431 14.109,8.079Z" + android:fillColor="#B1B5BA"/> +</vector> diff --git a/app/src/main/res/drawable/white_rounded_square_outline.xml b/app/src/main/res/drawable/white_rounded_square_outline.xml new file mode 100644 index 0000000000000000000000000000000000000000..5e52b28c38a41e2e39df01c33ca7e1d0d4c84557 --- /dev/null +++ b/app/src/main/res/drawable/white_rounded_square_outline.xml @@ -0,0 +1,5 @@ +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <corners android:radius="@dimen/spacing_30"/> + <stroke android:width="2dp" android:color="@color/white"/> +</shape> \ No newline at end of file diff --git a/app/src/main/res/layout/component_menu.xml b/app/src/main/res/layout/component_menu.xml index 6f964e4af1d690820d6f09f7bbc1fb3bc02b62e6..96d721f939ee86793e1652dd54f06143ae2928cf 100644 --- a/app/src/main/res/layout/component_menu.xml +++ b/app/src/main/res/layout/component_menu.xml @@ -129,8 +129,7 @@ app:layout_constraintEnd_toEndOf="@id/menuGuideEnd" app:layout_constraintHorizontal_bias="1" app:layout_constraintStart_toEndOf="@id/menuBarrierUsername" - app:layout_constraintTop_toTopOf="@id/menuTopText" - app:layout_constraintVertical_bias="0"/> + app:layout_constraintTop_toTopOf="@id/menuTopText" /> <!-- CHATS --> <ImageView @@ -163,7 +162,7 @@ <!-- CONTACTS --> <ImageView android:id="@+id/menuContactsIcon" - android:src="@drawable/ic_menu_contacts" + android:src="@drawable/ic_connections" app:layout_constraintStart_toEndOf="@id/menuGuideStart" app:layout_constraintTop_toBottomOf="@id/menuChatsText" style="@style/menu_icon" /> @@ -188,7 +187,7 @@ <!-- REQUESTS --> <ImageView android:id="@+id/menuContactRequestsIcon" - android:src="@drawable/ic_mail" + android:src="@drawable/ic_requests" app:layout_constraintStart_toEndOf="@id/menuGuideStart" app:layout_constraintTop_toBottomOf="@id/menuContactsTxt" style="@style/menu_icon" /> @@ -262,11 +261,12 @@ <!-- SCAN --> <!-- SETTINGS --> + <ImageView android:id="@+id/menuSettingsIcon" android:src="@drawable/ic_menu_settings" app:layout_constraintStart_toEndOf="@id/menuGuideStart" - app:layout_constraintTop_toBottomOf="@id/menuJoinXxTxt" + app:layout_constraintTop_toBottomOf="@id/menuScanText" style="@style/menu_icon" /> <TextView @@ -290,9 +290,9 @@ android:layout_height="@dimen/spacing_24" android:layout_marginTop="@dimen/spacing_24" android:padding="@dimen/spacing_3" - android:src="@drawable/ic_dashboard" + android:src="@drawable/ic_dashboard_24px" app:layout_constraintStart_toEndOf="@id/menuGuideStart" - app:layout_constraintTop_toBottomOf="@id/menuScanText" + app:layout_constraintTop_toBottomOf="@id/menuSettingsTxt" style="@style/menu_icon" /> <TextView @@ -336,6 +336,31 @@ app:layout_constraintStart_toEndOf="@id/menuJoinXxIcon" app:layout_constraintTop_toTopOf="@id/menuJoinXxIcon" /> + <ImageView + android:id="@+id/menuShareIcon" + android:layout_width="@dimen/spacing_24" + android:layout_height="@dimen/spacing_24" + android:layout_marginTop="@dimen/spacing_24" + android:src="@drawable/ic_share" + app:layout_constraintStart_toEndOf="@id/menuGuideStart" + app:layout_constraintTop_toBottomOf="@id/menuJoinXxTxt" + style="@style/menu_icon" /> + + <TextView + android:id="@+id/menuShareText" + style="@style/XxTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_10" + android:contentDescription="menu.option.dashboard" + android:padding="@dimen/spacing_8" + android:text="Share my profile" + android:textColor="@color/neutral_weak" + android:textSize="@dimen/text_14" + app:layout_constraintBottom_toBottomOf="@id/menuShareIcon" + app:layout_constraintStart_toEndOf="@id/menuShareIcon" + app:layout_constraintTop_toTopOf="@id/menuShareIcon" /> + <TextView android:id="@+id/menuBottomBuildTxt" style="@style/XxTextStyle.SemiBold" @@ -348,7 +373,7 @@ android:textSize="@dimen/text_12" app:layout_constraintBottom_toTopOf="@id/menuBottomVersionTxt" app:layout_constraintStart_toEndOf="@id/menuGuideStart" - app:layout_constraintTop_toBottomOf="@id/menuSettingsTxt" + app:layout_constraintTop_toBottomOf="@id/menuShareText" app:layout_constraintVertical_bias="1" app:layout_constraintVertical_chainStyle="packed" tools:text="Build 1.1.0" /> diff --git a/app/src/main/res/layout/component_toolbar_generic.xml b/app/src/main/res/layout/component_toolbar_generic.xml index 91641055cabd1160616459cb6ca69aa609df520f..e855c5b92bbae0e50ed304da4aa2a285e97a4d13 100644 --- a/app/src/main/res/layout/component_toolbar_generic.xml +++ b/app/src/main/res/layout/component_toolbar_generic.xml @@ -120,6 +120,7 @@ android:layout_width="0dp" android:layout_height="1dp" android:background="@color/neutral_line" + android:visibility="gone" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> diff --git a/app/src/main/res/layout/fragment_chats_list.xml b/app/src/main/res/layout/fragment_chats_list.xml index a9089f9e9c6ebcdf648e3b3b2262c3af9533a3e5..773edfdebd1a6778471f403db8527091bb8d34b2 100755 --- a/app/src/main/res/layout/fragment_chats_list.xml +++ b/app/src/main/res/layout/fragment_chats_list.xml @@ -140,6 +140,7 @@ android:layout_height="1dp" android:layout_marginTop="@dimen/spacing_12" android:background="@color/neutral_line" + android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/chatsMenu" /> diff --git a/app/src/main/res/layout/fragment_fact_search.xml b/app/src/main/res/layout/fragment_fact_search.xml new file mode 100644 index 0000000000000000000000000000000000000000..4784c1aada3699e8383b408e9dc8e2c7ff744a7c --- /dev/null +++ b/app/src/main/res/layout/fragment_fact_search.xml @@ -0,0 +1,77 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + <data> + <variable + name="ui" + type="io.xxlabs.messenger.search.FactSearchUi" /> + </data> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/countryButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="ud.search.input.dial.code" + android:textColor="@color/textInputFieldActive" + android:textSize="@dimen/text_14" + android:visibility="@{ui.countryCode}" + android:text="@{ui.countryCode}" + android:background="@drawable/bg_rectangle_rounded_corners_big_radius" + android:backgroundTint="@color/neutral_off_white" + android:padding="10dp" + android:paddingHorizontal="12dp" + android:onClick="@{() -> ui.onCountryClicked()}" + app:layout_constraintBottom_toBottomOf="@id/searchTextInput" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="@id/searchTextInput" + tools:visibility="visible" + tools:text="🇺🇸 +1"> + </com.google.android.material.textview.MaterialTextView> + + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/searchTextInput" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_16" + android:layout_marginStart="8dp" + app:layout_goneMarginStart="0dp" + app:endIconMode="clear_text" + app:errorEnabled="false" + app:helperTextEnabled="false" + app:hintEnabled="false" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toEndOf="@id/countryButton" + app:layout_constraintEnd_toEndOf="parent"> + + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/searchTextInputEditText" + style="@style/InputEditText.Search" + android:layout_width="match_parent" + android:layout_height="@dimen/spacing_40" + android:layout_gravity="center" + android:contentDescription="chats.input.search" + android:hint="@{ui.searchHint}" + android:afterTextChanged="@{ui::onSearchInput}" + android:enabled="@{ui.userInputEnabled}" + android:maxHeight="@dimen/spacing_40" + app:layout_goneMarginTop="@dimen/spacing_28" /> + </com.google.android.material.textfield.TextInputLayout> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/searchResultsRV" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginTop="@dimen/registration_body_vertical_margin" + app:layout_constraintTop_toBottomOf="@id/searchTextInput" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent"/> + </androidx.constraintlayout.widget.ConstraintLayout> +</layout> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_private_search.xml b/app/src/main/res/layout/fragment_private_search.xml index 1f7a744816d1c69fec647cd316c0a6b354970bef..56a59c60fc69a202441007943aad8670e8dbe251 100644 --- a/app/src/main/res/layout/fragment_private_search.xml +++ b/app/src/main/res/layout/fragment_private_search.xml @@ -1,206 +1,214 @@ <?xml version="1.0" encoding="utf-8"?> -<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" +<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="@color/background" - android:fillViewport="true"> + xmlns:tools="http://schemas.android.com/tools"> - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/udSearch" + <data> + <variable + name="ui" + type="io.xxlabs.messenger.search.UdSearchUi" /> + </data> + + <androidx.core.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="match_parent" - android:clipChildren="false" - android:clipToPadding="false" - android:descendantFocusability="beforeDescendants" - android:focusableInTouchMode="true"> - - <include layout="@layout/component_toolbar_generic" /> + android:background="@color/background" + android:fillViewport="true"> - <LinearLayout - android:id="@+id/udSearchUsernameLayout" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_20" - android:contentDescription="ud.search.selection.username" - android:gravity="center_horizontal" - android:orientation="horizontal" - android:padding="@dimen/spacing_12" - app:layout_constraintEnd_toStartOf="@id/udSearchEmailLayout" - app:layout_constraintHorizontal_bias="0" - app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" - app:layout_constraintTop_toBottomOf="@id/toolbarGeneric"> + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/udSearch" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false" + android:descendantFocusability="beforeDescendants" + android:focusableInTouchMode="true"> - <ImageView - android:id="@+id/udSearchUsernameIcon" - android:layout_width="@dimen/spacing_20" - android:layout_height="@dimen/spacing_20" - android:src="@drawable/ic_contact_light" /> + <include layout="@layout/component_toolbar_generic" /> - <TextView - android:id="@+id/udSearchUsernameText" - style="@style/XxTextStyle.SemiBold" + <LinearLayout + android:id="@+id/udSearchUsernameLayout" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_8" - android:text="Username" - android:textColor="@color/neutral_dark" - android:textSize="@dimen/text_14" /> - </LinearLayout> + android:layout_marginTop="@dimen/spacing_20" + android:contentDescription="ud.search.selection.username" + android:gravity="center_horizontal" + android:orientation="horizontal" + android:padding="@dimen/spacing_12" + app:layout_constraintEnd_toStartOf="@id/udSearchEmailLayout" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" + app:layout_constraintTop_toBottomOf="@id/toolbarGeneric"> - <LinearLayout - android:id="@+id/udSearchEmailLayout" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_16" - android:gravity="center_horizontal" - android:contentDescription="ud.search.selection.email" - android:orientation="horizontal" - android:padding="@dimen/spacing_12" - app:layout_constraintEnd_toStartOf="@id/udSearchPhoneLayout" - app:layout_constraintStart_toEndOf="@id/udSearchUsernameLayout" - app:layout_constraintTop_toTopOf="@id/udSearchUsernameLayout"> + <ImageView + android:id="@+id/udSearchUsernameIcon" + android:layout_width="@dimen/spacing_20" + android:layout_height="@dimen/spacing_20" + android:src="@drawable/ic_contact_light" /> + + <TextView + android:id="@+id/udSearchUsernameText" + style="@style/XxTextStyle.SemiBold" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_8" + android:text="Username" + android:textColor="@color/neutral_dark" + android:textSize="@dimen/text_14" /> + </LinearLayout> - <ImageView - android:id="@+id/udSearchEmailIcon" + <LinearLayout + android:id="@+id/udSearchEmailLayout" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:src="@drawable/ic_mail" - app:tint="@color/neutral_dark" /> + android:layout_marginStart="@dimen/spacing_16" + android:gravity="center_horizontal" + android:contentDescription="ud.search.selection.email" + android:orientation="horizontal" + android:padding="@dimen/spacing_12" + app:layout_constraintEnd_toStartOf="@id/udSearchPhoneLayout" + app:layout_constraintStart_toEndOf="@id/udSearchUsernameLayout" + app:layout_constraintTop_toTopOf="@id/udSearchUsernameLayout"> - <TextView - android:id="@+id/udSearchEmailText" - style="@style/XxTextStyle.SemiBold" + <ImageView + android:id="@+id/udSearchEmailIcon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:src="@drawable/ic_mail" + app:tint="@color/neutral_dark" /> + + <TextView + android:id="@+id/udSearchEmailText" + style="@style/XxTextStyle.SemiBold" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_8" + android:text="Email" + android:textColor="@color/neutral_dark" + android:textSize="@dimen/text_14" /> + </LinearLayout> + + <LinearLayout + android:id="@+id/udSearchPhoneLayout" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_16" + android:gravity="center_horizontal" + android:orientation="horizontal" + android:contentDescription="ud.search.selection.phone" + android:padding="@dimen/spacing_12" + app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" + app:layout_constraintStart_toEndOf="@id/udSearchEmailLayout" + app:layout_constraintTop_toTopOf="@id/udSearchEmailLayout"> + + <ImageView + android:id="@+id/udSearchPhoneIcon" + android:layout_width="@dimen/spacing_20" + android:layout_height="@dimen/spacing_20" + android:src="@drawable/ic_phone" + app:tint="@color/neutral_dark" /> + + <TextView + android:id="@+id/udSearchPhoneText" + style="@style/XxTextStyle.SemiBold" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_8" + android:text="Phone" + android:textColor="@color/neutral_dark" + android:textSize="@dimen/text_14" /> + </LinearLayout> + + <androidx.appcompat.widget.AppCompatEditText + android:id="@+id/udSearchInput" + style="@style/InputEditText.Search.Squared" + android:layout_width="0dp" + android:layout_height="@dimen/spacing_40" + android:contentDescription="ud.search.input.default" android:layout_marginStart="@dimen/spacing_8" - android:text="Email" - android:textColor="@color/neutral_dark" - android:textSize="@dimen/text_14" /> - </LinearLayout> + android:layout_marginTop="@dimen/spacing_16" + android:hint="@string/search_hint" + android:imeOptions="actionSearch" + android:inputType="textNoSuggestions" + android:lines="1" + android:maxHeight="@dimen/spacing_40" + android:textSize="@dimen/text_14" + app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" + app:layout_constraintStart_toEndOf="@id/udSearchInputPhoneCode" + app:layout_constraintTop_toBottomOf="@id/udSearchBarrier" /> - <LinearLayout - android:id="@+id/udSearchPhoneLayout" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_16" - android:gravity="center_horizontal" - android:orientation="horizontal" - android:contentDescription="ud.search.selection.phone" - android:padding="@dimen/spacing_12" - app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" - app:layout_constraintStart_toEndOf="@id/udSearchEmailLayout" - app:layout_constraintTop_toTopOf="@id/udSearchEmailLayout"> + <androidx.appcompat.widget.AppCompatEditText + android:id="@+id/udSearchInputPhoneCode" + style="@style/InputEditText" + android:layout_width="wrap_content" + android:layout_height="0dp" + android:contentDescription="ud.search.input.dial.code" + android:cursorVisible="false" + android:focusable="false" + android:focusableInTouchMode="false" + android:hint="🇺🇸 +1" + android:maxLength="10" + android:minEms="4" + android:textColor="@color/textInputFieldActive" + android:textSize="@dimen/text_14" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="@id/udSearchInput" + app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" + app:layout_constraintTop_toTopOf="@id/udSearchInput" /> - <ImageView - android:id="@+id/udSearchPhoneIcon" + <androidx.core.widget.ContentLoadingProgressBar + android:id="@+id/udSearchLoading" + style="@style/XxProgressBarCircularBlue" android:layout_width="@dimen/spacing_20" android:layout_height="@dimen/spacing_20" - android:src="@drawable/ic_phone" - app:tint="@color/neutral_dark" /> + android:layout_marginEnd="@dimen/spacing_16" + app:layout_constraintBottom_toBottomOf="@id/udSearchInput" + app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" + app:layout_constraintTop_toTopOf="@id/udSearchInput" /> <TextView - android:id="@+id/udSearchPhoneText" - style="@style/XxTextStyle.SemiBold" - android:layout_width="wrap_content" + android:id="@+id/udSearchResultsTitle" + style="@style/XxTextStyle.Bold" + android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_8" - android:text="Phone" - android:textColor="@color/neutral_dark" - android:textSize="@dimen/text_14" /> - </LinearLayout> - - <androidx.appcompat.widget.AppCompatEditText - android:id="@+id/udSearchInput" - style="@style/InputEditText.Search.Squared" - android:layout_width="0dp" - android:layout_height="@dimen/spacing_40" - android:contentDescription="ud.search.input.default" - android:layout_marginStart="@dimen/spacing_8" - android:layout_marginTop="@dimen/spacing_16" - android:hint="@string/search_hint" - android:imeOptions="actionSearch" - android:inputType="textNoSuggestions" - android:lines="1" - android:maxHeight="@dimen/spacing_40" - android:textSize="@dimen/text_14" - app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" - app:layout_constraintStart_toEndOf="@id/udSearchInputPhoneCode" - app:layout_constraintTop_toBottomOf="@id/udSearchBarrier"/> - - <androidx.appcompat.widget.AppCompatEditText - android:id="@+id/udSearchInputPhoneCode" - style="@style/InputEditText" - android:layout_width="wrap_content" - android:layout_height="0dp" - android:contentDescription="ud.search.input.dial.code" - android:cursorVisible="false" - android:focusable="false" - android:focusableInTouchMode="false" - android:hint="🇺🇸 +1" - android:maxLength="10" - android:minEms="4" - android:textColor="@color/textInputFieldActive" - android:textSize="@dimen/text_14" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="@id/udSearchInput" - app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" - app:layout_constraintTop_toTopOf="@id/udSearchInput" /> - - <androidx.core.widget.ContentLoadingProgressBar - android:id="@+id/udSearchLoading" - style="@style/XxProgressBarCircularBlue" - android:layout_width="@dimen/spacing_20" - android:layout_height="@dimen/spacing_20" - android:layout_marginEnd="@dimen/spacing_16" - app:layout_constraintBottom_toBottomOf="@id/udSearchInput" - app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" - app:layout_constraintTop_toTopOf="@id/udSearchInput" /> - - <TextView - android:id="@+id/udSearchResultsTitle" - style="@style/XxTextStyle.Bold" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_24" - android:paddingStart="@dimen/spacing_24" - android:paddingTop="@dimen/spacing_5" - android:paddingEnd="@dimen/spacing_24" - android:paddingBottom="@dimen/spacing_5" - android:text="Users" - android:textColor="@color/neutral_active" - android:textSize="@dimen/text_24" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@id/udSearchResultsRecyclerView" - app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" - app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" - app:layout_constraintTop_toBottomOf="@id/udSearchInput" - app:layout_constraintVertical_bias="0" /> + android:layout_marginTop="@dimen/spacing_24" + android:paddingStart="@dimen/spacing_24" + android:paddingTop="@dimen/spacing_5" + android:paddingEnd="@dimen/spacing_24" + android:paddingBottom="@dimen/spacing_5" + android:text="Users" + android:textColor="@color/neutral_active" + android:textSize="@dimen/text_24" + android:visibility="gone" + app:layout_constraintBottom_toTopOf="@id/udSearchResultsRecyclerView" + app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" + app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" + app:layout_constraintTop_toBottomOf="@id/udSearchInput" + app:layout_constraintVertical_bias="0" /> - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/udSearchResultsRecyclerView" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginTop="@dimen/spacing_34" - android:layout_marginBottom="@dimen/spacing_16" - android:background="@color/backgroundSecondary" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/udSearchResultsTitle" - app:layout_constraintVertical_bias="0" - tools:itemCount="2" - tools:listitem="@layout/list_item_contact_search_result" /> + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/udSearchResultsRecyclerView" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginTop="@dimen/spacing_34" + android:layout_marginBottom="@dimen/spacing_16" + android:background="@color/backgroundSecondary" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/udSearchResultsTitle" + app:layout_constraintVertical_bias="0" + tools:itemCount="2" + tools:listitem="@layout/list_item_contact_search_result" /> - <androidx.constraintlayout.widget.Barrier - android:id="@+id/udSearchBarrier" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:barrierDirection="bottom" - app:constraint_referenced_ids="udSearchUsernameLayout, udSearchEmailLayout, udSearchPhoneLayout" - tools:layout_editor_absoluteY="121dp" /> + <androidx.constraintlayout.widget.Barrier + android:id="@+id/udSearchBarrier" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:barrierDirection="bottom" + app:constraint_referenced_ids="udSearchUsernameLayout, udSearchEmailLayout, udSearchPhoneLayout" + tools:layout_editor_absoluteY="121dp" /> <TextView @@ -212,7 +220,6 @@ android:layout_marginTop="156dp" android:clickable="true" android:contentDescription="ud.search.empty.text" - android:drawableBottom="@drawable/ic_outline_info_24" android:focusable="true" android:gravity="center_horizontal" android:letterSpacing="0.01" @@ -224,43 +231,44 @@ app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" app:layout_constraintTop_toBottomOf="@id/udSearchInput" /> - <TextView - android:id="@+id/udSearchInputEmptyMessage" - style="@style/XxTextStyle.SemiBold" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_50" - android:gravity="center" - android:letterSpacing="0.01" - android:textColor="@color/neutral_body" - android:textSize="@dimen/text_14" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" - app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" - app:layout_constraintTop_toBottomOf="@id/udSearchInput" - app:layout_constraintVertical_bias="0.2" - tools:text="Abcdef" /> + <TextView + android:id="@+id/udSearchInputEmptyMessage" + style="@style/XxTextStyle.SemiBold" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_50" + android:gravity="center" + android:letterSpacing="0.01" + android:textColor="@color/neutral_body" + android:textSize="@dimen/text_14" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="@id/udSearchGuideEnd" + app:layout_constraintStart_toStartOf="@id/udSearchGuideStart" + app:layout_constraintTop_toBottomOf="@id/udSearchInput" + app:layout_constraintVertical_bias="0.2" + tools:text="Abcdef" /> - <androidx.constraintlayout.widget.Guideline - android:id="@+id/udSearchGuideStart" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent=".05" /> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/udSearchGuideStart" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_percent=".05" /> - <androidx.constraintlayout.widget.Guideline - android:id="@+id/udSearchGuideMiddle" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent="0.50" /> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/udSearchGuideMiddle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_percent="0.50" /> - <androidx.constraintlayout.widget.Guideline - android:id="@+id/udSearchGuideEnd" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent=".95" /> - </androidx.constraintlayout.widget.ConstraintLayout> -</androidx.core.widget.NestedScrollView> \ No newline at end of file + <androidx.constraintlayout.widget.Guideline + android:id="@+id/udSearchGuideEnd" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_percent=".95" /> + </androidx.constraintlayout.widget.ConstraintLayout> + </androidx.core.widget.NestedScrollView> +</layout> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_user_search.xml b/app/src/main/res/layout/fragment_user_search.xml new file mode 100644 index 0000000000000000000000000000000000000000..e66bfcd667569654c01fe51f507ed38361570979 --- /dev/null +++ b/app/src/main/res/layout/fragment_user_search.xml @@ -0,0 +1,143 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout xmlns:tools="http://schemas.android.com/tools" + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <data> + <variable + name="ui" + type="io.xxlabs.messenger.search.UdSearchUi" /> + </data> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false" + android:descendantFocusability="beforeDescendants" + android:focusableInTouchMode="true"> + + <include layout="@layout/component_toolbar_generic" /> + + <com.google.android.material.appbar.AppBarLayout + android:id="@+id/userSearchAppBarLayout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/transparent" + android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" + app:elevation="0dp" + app:layout_constraintTop_toBottomOf="@id/toolbarGeneric"> + + <com.google.android.material.tabs.TabLayout + android:id="@+id/userSearchAppBarTabs" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/transparent" + app:tabGravity="fill" + app:tabIconTint="@color/selector_qr_code_tab_secondary" + app:tabIndicatorFullWidth="true" + app:tabIndicatorHeight="@dimen/spacing_3" + app:tabInlineLabel="false" + app:tabMaxWidth="0dp" + app:tabMode="fixed" + app:tabPaddingEnd="8dp" + app:tabPaddingStart="8dp" + app:tabTextAppearance="@style/TabTextStyle" + app:tabTextColor="@color/selector_qr_code_tab_secondary" /> + </com.google.android.material.appbar.AppBarLayout> + + <androidx.viewpager2.widget.ViewPager2 + android:id="@+id/userSearchViewPager" + android:layout_width="match_parent" + android:layout_height="0dp" + android:orientation="horizontal" + android:paddingHorizontal="@dimen/spacing_14" + android:paddingBottom="@dimen/spacing_14" + app:layout_behavior="@string/appbar_scrolling_view_behavior" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/userSearchAppBarLayout" /> + + <TextView + android:id="@+id/userSearchCallToAction" + style="@style/dialog_title" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_margin="@dimen/spacing_34" + android:letterSpacing="0.01" + android:text="@{ui.callToActionText}" + tools:text="@string/search_call_to_action" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:lineHeight="40sp" /> + + <TextView + android:id="@+id/userSearchInfoText" + style="@style/XxTextStyle" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_margin="@dimen/spacing_34" + android:layout_marginTop="0dp" + android:clickable="true" + android:contentDescription="ud.search.empty.text" + android:focusable="true" + android:letterSpacing="0.01" + android:text="@{ui.placeholderText}" + android:textColor="@color/neutral_body" + android:textSize="@dimen/text_18" + android:textStyle="normal" + android:onClick="@{() -> ui.onPlaceholderClicked()}" + tools:text="@string/search_placeholder_text" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/userSearchCallToAction" /> + + <View + android:id="@+id/fadeView" + android:layout_width="0dp" + android:layout_height="0dp" + android:alpha=".8" + android:background="@color/black" + android:visibility="@{ui.isSearching}" + android:elevation="5dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:visibility="visible" /> + + <ProgressBar + android:id="@+id/progressbar" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:indeterminate="true" + android:visibility="@{ui.isSearching}" + android:elevation="5dp" + android:indeterminateTint="@color/white" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <Button + android:id="@+id/userSearchCancelButton" + style="@style/dialog_button" + android:layout_width="0dp" + android:layout_marginBottom="@dimen/registration_body_vertical_margin" + android:elevation="5dp" + android:onClick="@{() -> ui.onCancelSearchClicked()}" + android:stateListAnimator="@null" + android:text="@string/cancel" + android:visibility="@{ui.isSearching}" + android:background="@drawable/white_rounded_square_outline" + android:textColor="@color/white" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/progressbar" + app:layout_constraintVertical_bias="0.75" /> + </androidx.constraintlayout.widget.ConstraintLayout> +</layout> \ No newline at end of file diff --git a/app/src/main/res/layout/list_item_request.xml b/app/src/main/res/layout/list_item_request.xml index 66d700bf9fe2a7b4b84f12284df978b51ffc9b4f..d0dc187fc3269c5ff653f2e942e8e94e64743b10 100644 --- a/app/src/main/res/layout/list_item_request.xml +++ b/app/src/main/res/layout/list_item_request.xml @@ -33,7 +33,9 @@ style="@style/request_item_header" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginBottom="4dp" android:text="@{ui.title}" + app:layout_constraintBottom_toTopOf="@+id/requestSubtitle" app:layout_constraintEnd_toStartOf="@id/requestAction" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toEndOf="@id/requestProfilePhoto" @@ -47,6 +49,7 @@ android:layout_height="wrap_content" android:text="@{ui.subtitle}" android:visibility="@{ui.subtitle}" + app:layout_constraintBottom_toTopOf="@+id/requestDetails" app:layout_constraintEnd_toStartOf="@id/requestAction" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toEndOf="@id/requestProfilePhoto" @@ -58,9 +61,11 @@ style="@style/request_item_body" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginBottom="4dp" android:lineSpacingExtra="2sp" android:text="@{ui.details}" android:visibility="@{ui.details}" + app:layout_constraintBottom_toTopOf="@+id/requestTimestamp" app:layout_constraintEnd_toStartOf="@id/requestAction" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toEndOf="@id/requestProfilePhoto" @@ -72,7 +77,9 @@ style="@style/request_item_timestamp" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginBottom="4dp" app:date="@{ui.timestamp}" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/requestAction" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toEndOf="@id/requestProfilePhoto" diff --git a/app/src/main/res/layout/list_item_request_search_result.xml b/app/src/main/res/layout/list_item_request_search_result.xml new file mode 100644 index 0000000000000000000000000000000000000000..84d3ecc71665c8514dd0181fb6e9690611c05578 --- /dev/null +++ b/app/src/main/res/layout/list_item_request_search_result.xml @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + <data> + <import type="io.xxlabs.messenger.ui.main.chat.DrawablePosition"/> + <variable + name="ui" + type="io.xxlabs.messenger.requests.ui.list.adapter.ContactRequestSearchResultItem" /> + <variable + name="listener" + type="io.xxlabs.messenger.requests.ui.list.adapter.RequestItemListener" /> + </data> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/requestItemLayout" + android:padding="16dp" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:onClick="@{() -> listener.onItemClicked(ui)}" > + + <include + android:id="@+id/requestProfilePhoto" + layout="@layout/component_item_thumbnail" + android:layout_width="@dimen/spacing_42" + android:layout_height="@dimen/spacing_42" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:ui="@{ui}" /> + + <TextView + android:id="@+id/requestTitle" + style="@style/request_item_header" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="4dp" + android:text="@{ui.title}" + app:layout_constraintBottom_toTopOf="@+id/requestSubtitle" + app:layout_constraintEnd_toStartOf="@id/requestAction" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toEndOf="@id/requestProfilePhoto" + app:layout_constraintTop_toTopOf="@id/requestProfilePhoto" + tools:text="bartender007" /> + + <TextView + android:id="@+id/requestSubtitle" + style="@style/request_item_subheader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@{ui.subtitle}" + android:visibility="@{ui.subtitle}" + android:textColor="@{ui.statusTextColor}" + app:layout_constraintEnd_toStartOf="@id/requestAction" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toEndOf="@id/requestProfilePhoto" + app:layout_constraintTop_toBottomOf="@id/requestTitle" + tools:text="Request pending" + tools:textColor="@color/neutral_weak"/> + + <io.xxlabs.messenger.support.view.SingleClickTextView + android:id="@+id/requestAction" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@{ui.actionLabel}" + android:padding="12dp" + android:layout_marginHorizontal="0dp" + android:drawablePadding="4dp" + android:onClick="@{() -> listener.onActionClicked(ui)}" + android:visibility="@{ui.actionVisible}" + android:singleLine="false" + android:gravity="center_vertical|end" + app:actionIcon="@{ui.actionIcon}" + app:iconColor="@{ui.actionIconColor}" + app:iconPosition="@{DrawablePosition.START}" + app:customStyle="@{ui.actionTextStyle}" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + tools:drawableLeft="@drawable/ic_retry" + tools:text="Resend" + style="@style/request_item_subheader"/> + </androidx.constraintlayout.widget.ConstraintLayout> +</layout> \ No newline at end of file diff --git a/app/src/main/res/layout/list_item_section_divider.xml b/app/src/main/res/layout/list_item_section_divider.xml new file mode 100644 index 0000000000000000000000000000000000000000..30cb0cc5283f5cad52ab95c47a0e9c449a782e23 --- /dev/null +++ b/app/src/main/res/layout/list_item_section_divider.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + <data> + <variable + name="ui" + type="io.xxlabs.messenger.requests.ui.list.adapter.RequestItem" /> + </data> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="16dp" + android:paddingBottom="24dp"> + + <View + android:id="@+id/divider" + android:layout_width="0dp" + android:layout_height="1dp" + android:background="@color/neutral_line" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + + <TextView + android:id="@+id/sectionLabel" + style="@style/requests_list_hidden_requests_label" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:text="@{ui.title}" + android:layout_marginTop="24dp" + android:textAllCaps="true" + android:textColor="@color/neutral_weak" + android:textSize="12sp" + app:layout_constraintTop_toBottomOf="@id/divider" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + tools:text="Local Connections"/> + </androidx.constraintlayout.widget.ConstraintLayout> +</layout> \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 62a0601f45412d77116d0f640874553ff129819f..b5e8db61fb5d600fffb25c7fa669dfd0c73cd4c4 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -309,9 +309,9 @@ <!-- TODO: Add a global search action --> <fragment android:id="@+id/udPrivateSearchFragment" - android:name="io.xxlabs.messenger.ui.main.ud.search.UdSearchFragment" + android:name="io.xxlabs.messenger.search.UserSearchFragment" android:label="UdPrivateSearchFragment" - tools:layout="@layout/fragment_private_search"> + tools:layout="@layout/fragment_user_search"> <action android:id="@+id/action_ud_search_to_contact_success" @@ -320,6 +320,11 @@ app:exitAnim="@anim/slide_out_left" app:popEnterAnim="@anim/slide_in_left" app:popExitAnim="@anim/slide_out_right" /> + <argument + android:name="username" + app:argType="string" + app:nullable="true" + android:defaultValue="@null" /> </fragment> <fragment @@ -354,6 +359,15 @@ android:label="UdProfileFragment" tools:layout="@layout/fragment_ud_profile" /> + <action + android:id="@+id/action_global_connection_invitation" + app:destination="@id/udPrivateSearchFragment" + app:enterAnim="@anim/slide_in_right" + app:exitAnim="@anim/slide_out_left" + app:popEnterAnim="@anim/slide_in_left" + app:popExitAnim="@anim/slide_out_right" /> + + <action android:id="@+id/action_global_contact_invitation" app:destination="@id/contactInvitationFragment" diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index da985b1aff4d14d0b2b483422863680f162eb439..83d57dc0600cbb803c727d77758629f3a9d1dd46 100755 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -194,5 +194,5 @@ <color name="accent_success">#2CC069</color> <color name="accent_safe">#FE7751</color> - <color name="menu_background">#252525</color> + <color name="menu_background">@color/neutral_dark</color> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a78a0e24eb3a3c6876d9a24606a8896fedbb1a8..7a8bab9d42bf236571ab91bf2b88acadba3759e3 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -328,7 +328,8 @@ <!-- Search --> - <string name="search_info_dialog_title">Search</string> + <string name="search_title">Search</string> + <string name="search_info_dialog_title">@string/search_title</string> <string name="search_info_dialog_body"> You can search for users by their username, email, or phone number using the xx network’s Anonymous Data Retrieval protocol which keeps a user’s identity anonymous @@ -339,8 +340,14 @@ <string name="search_info_dialog_link_url">https://links.xx.network/adrp</string> <string name="search_placeholder_text"> Your searches are anonymous. Search information is never linked to your account or - personally identifiable.</string> + personally identifiable. ⓘ</string> + <string name="search_placeholder_span">ⓘ</string> <string name="search_hint">Search</string> + <string name="search_call_to_action"> + Search for friends anonymously, add them to your connections to start a completely + private messaging channel.</string> + <string name="search_call_to_action_span_1">friends</string> + <string name="search_call_to_action_span_2">connections</string> <!-- Group Chat --> @@ -626,4 +633,15 @@ <string name="sftp_login_password_hint">Password</string> <string name="sftp_login_port_hint">Port (leave blank for default TCP 22)</string> <string name="registration_restore_disabled_error">A username has been submitted. Please reinstall to use restore.</string> + + <string name="share_profile_label">Share profile</string> + <string name="share_invitation_message"> + Hi, I\'m using xx messenger, you can download it here: + \nhttps://invite.xx.network + \n\nAnd you can add me here: + \nhttps://elixxir.io/connect?username=%s + </string> + <string name="share_chooser_title">@string/share_profile_label</string> + <string name="share_no_activity_error">There are no apps installed to share to.</string> + <string name="search_generic_error_message">Couldn\'t complete search. Please try again.</string> </resources> \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index caee9031cd2c455be60c80cebaf74418deba8f70..f1649964ad00808f7077d686d310c1bdfca635dd 100755 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -371,6 +371,9 @@ <item name="android:textSize">@dimen/text_14</item> </style> + <style name="SmallTextTabStyle" parent="XxTextStyle"> + <item name="android:textSize">12sp</item> + </style> <!-- New Styles --> <style name="InputLayout" parent="Widget.Design.TextInputLayout">