From 67c6d5675e7aa6817a44bbd5135f01248ff67785 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 27 Feb 2019 15:43:53 +0100 Subject: [PATCH] Begin implementing username autocomplete --- Habitica/res/layout/autocomplete_username.xml | 17 +++++ .../res/layout/tavern_chat_new_entry_item.xml | 2 +- .../android/habitica/api/ApiService.java | 4 ++ .../habitica/api/GSonFactoryCreator.java | 3 + .../habitica/components/AppComponent.java | 3 + .../android/habitica/data/ApiClient.kt | 2 + .../android/habitica/data/SocialRepository.kt | 7 +- .../data/implementation/ApiClientImpl.kt | 5 ++ .../implementation/SocialRepositoryImpl.kt | 10 +-- .../models/social/FindUsernameResult.kt | 24 +++++++ .../ui/helpers/AutocompleteAdapter.kt | 64 +++++++++++++++++++ .../habitica/ui/helpers/ChatInputTokenizer.kt | 60 +++++++++++++++++ .../habitica/ui/views/social/ChatBarView.kt | 26 ++++++-- .../utils/FindUsernameResultDeserializer.kt | 28 ++++++++ 14 files changed, 240 insertions(+), 15 deletions(-) create mode 100644 Habitica/res/layout/autocomplete_username.xml create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/models/social/FindUsernameResult.kt create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/AutocompleteAdapter.kt create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/ChatInputTokenizer.kt create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/utils/FindUsernameResultDeserializer.kt diff --git a/Habitica/res/layout/autocomplete_username.xml b/Habitica/res/layout/autocomplete_username.xml new file mode 100644 index 000000000..a413fd56c --- /dev/null +++ b/Habitica/res/layout/autocomplete_username.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/Habitica/res/layout/tavern_chat_new_entry_item.xml b/Habitica/res/layout/tavern_chat_new_entry_item.xml index 8f6c2c4dc..dd6d74035 100644 --- a/Habitica/res/layout/tavern_chat_new_entry_item.xml +++ b/Habitica/res/layout/tavern_chat_new_entry_item.xml @@ -34,7 +34,7 @@ app:hintEnabled="false" android:layout_marginBottom="@dimen/spacing_medium" android:layout_marginTop="@dimen/spacing_medium"> - > postPrivateMessage(@Body Map messageDetails); + @GET("members/find/{username}") + Flowable>> findUsernames(@Path("username") String username, @Query("context") String context, @Query("id") String id); + @GET("shops/{identifier}") Flowable> retrieveShopInventory(@Path("identifier") String identifier); @GET("shops/market-gear") diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/api/GSonFactoryCreator.java b/Habitica/src/main/java/com/habitrpg/android/habitica/api/GSonFactoryCreator.java index 74701d2a4..2d01c1960 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/api/GSonFactoryCreator.java +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/api/GSonFactoryCreator.java @@ -24,6 +24,7 @@ import com.habitrpg.android.habitica.models.members.Member; import com.habitrpg.android.habitica.models.responses.FeedResponse; import com.habitrpg.android.habitica.models.social.Challenge; import com.habitrpg.android.habitica.models.social.ChatMessage; +import com.habitrpg.android.habitica.models.social.FindUsernameResult; import com.habitrpg.android.habitica.models.social.Group; import com.habitrpg.android.habitica.models.tasks.Task; import com.habitrpg.android.habitica.models.tasks.TaskList; @@ -41,6 +42,7 @@ import com.habitrpg.android.habitica.utils.EggListDeserializer; import com.habitrpg.android.habitica.utils.EquipmentListDeserializer; import com.habitrpg.android.habitica.utils.FAQArticleListDeserilializer; import com.habitrpg.android.habitica.utils.FeedResponseDeserializer; +import com.habitrpg.android.habitica.utils.FindUsernameResultDeserializer; import com.habitrpg.android.habitica.utils.FoodListDeserializer; import com.habitrpg.android.habitica.utils.GroupSerialization; import com.habitrpg.android.habitica.utils.HatchingPotionListDeserializer; @@ -130,6 +132,7 @@ public class GSonFactoryCreator { .registerTypeAdapter(Quest.class, new QuestDeserializer()) .registerTypeAdapter(Member.class, new MemberSerialization()) .registerTypeAdapter(WorldState.class, new WorldStateSerialization()) + .registerTypeAdapter(FindUsernameResult.class, new FindUsernameResultDeserializer()) //.registerTypeAdapter(Task.class, new TaskDeserializer()) .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") .create(); diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/components/AppComponent.java b/Habitica/src/main/java/com/habitrpg/android/habitica/components/AppComponent.java index 42989e025..645ee3e22 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/components/AppComponent.java +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/components/AppComponent.java @@ -92,6 +92,7 @@ import com.habitrpg.android.habitica.ui.fragments.social.party.PartyMemberListFr import com.habitrpg.android.habitica.ui.fragments.tasks.TaskRecyclerViewFragment; import com.habitrpg.android.habitica.ui.fragments.tasks.TasksFragment; import com.habitrpg.android.habitica.ui.viewmodels.GroupViewModel; +import com.habitrpg.android.habitica.ui.views.social.ChatBarView; import com.habitrpg.android.habitica.ui.views.stats.BulkAllocateStatsDialog; import com.habitrpg.android.habitica.ui.views.shops.PurchaseDialog; import com.habitrpg.android.habitica.ui.views.tasks.TaskFilterDialog; @@ -308,4 +309,6 @@ public interface AppComponent { void inject(@NotNull GiftIAPActivity giftIAPActivity); void inject(@NotNull AboutFragment aboutFragment); + + void inject(@NotNull ChatBarView chatBarView); } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt index 1622fe670..40bc07022 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt @@ -11,6 +11,7 @@ import com.habitrpg.android.habitica.models.shops.Shop import com.habitrpg.android.habitica.models.shops.ShopItem import com.habitrpg.android.habitica.models.social.Challenge import com.habitrpg.android.habitica.models.social.ChatMessage +import com.habitrpg.android.habitica.models.social.FindUsernameResult import com.habitrpg.android.habitica.models.social.Group import com.habitrpg.android.habitica.models.tasks.Task import com.habitrpg.android.habitica.models.tasks.TaskList @@ -248,4 +249,5 @@ interface ApiClient { fun retrieveMarketGear(): Flowable fun verifyUsername(username: String): Flowable fun updateServerUrl(newAddress: String?) + fun findUsernames(username: String, context: String?, id: String?): Flowable> } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/SocialRepository.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/SocialRepository.kt index 238339cd5..3f8eb5d2f 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/SocialRepository.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/SocialRepository.kt @@ -4,10 +4,7 @@ import com.habitrpg.android.habitica.models.AchievementResult import com.habitrpg.android.habitica.models.inventory.Quest import com.habitrpg.android.habitica.models.members.Member import com.habitrpg.android.habitica.models.responses.PostChatMessageResult -import com.habitrpg.android.habitica.models.social.Challenge -import com.habitrpg.android.habitica.models.social.ChatMessage -import com.habitrpg.android.habitica.models.social.Group -import com.habitrpg.android.habitica.models.social.GroupMembership +import com.habitrpg.android.habitica.models.social.* import com.habitrpg.android.habitica.models.user.User import io.reactivex.Flowable import io.reactivex.Single @@ -60,6 +57,8 @@ interface SocialRepository : BaseRepository { fun getMember(userId: String?): Flowable fun getMemberWithUsername(username: String?): Flowable + fun findUsernames(username: String, context: String? = null, id: String? = null): Flowable> + fun markPrivateMessagesRead(user: User?): Flowable fun acceptQuest(user: User?, partyId: String): Flowable diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt index f1edd32ab..66dfe714d 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt @@ -28,6 +28,7 @@ import com.habitrpg.android.habitica.models.shops.Shop import com.habitrpg.android.habitica.models.shops.ShopItem import com.habitrpg.android.habitica.models.social.Challenge import com.habitrpg.android.habitica.models.social.ChatMessage +import com.habitrpg.android.habitica.models.social.FindUsernameResult import com.habitrpg.android.habitica.models.social.Group import com.habitrpg.android.habitica.models.tasks.Task import com.habitrpg.android.habitica.models.tasks.TaskList @@ -583,6 +584,10 @@ class ApiClientImpl//private OnHabitsAPIResult mResultListener; return apiService.getMemberAchievements(memberId).compose(configureApiCallObserver()) } + override fun findUsernames(username: String, context: String?, id: String?): Flowable> { + return apiService.findUsernames(username, context, id).compose(configureApiCallObserver()) + } + override fun postPrivateMessage(messageDetails: Map): Flowable { return apiService.postPrivateMessage(messageDetails).compose(configureApiCallObserver()) } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/SocialRepositoryImpl.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/SocialRepositoryImpl.kt index 9bbf7d36b..38182c1f3 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/SocialRepositoryImpl.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/SocialRepositoryImpl.kt @@ -9,10 +9,7 @@ import com.habitrpg.android.habitica.models.AchievementResult import com.habitrpg.android.habitica.models.inventory.Quest import com.habitrpg.android.habitica.models.members.Member import com.habitrpg.android.habitica.models.responses.PostChatMessageResult -import com.habitrpg.android.habitica.models.social.Challenge -import com.habitrpg.android.habitica.models.social.ChatMessage -import com.habitrpg.android.habitica.models.social.Group -import com.habitrpg.android.habitica.models.social.GroupMembership +import com.habitrpg.android.habitica.models.social.* import com.habitrpg.android.habitica.models.user.User import io.reactivex.Flowable import io.reactivex.Single @@ -21,6 +18,7 @@ import io.reactivex.functions.Consumer import io.realm.RealmResults class SocialRepositoryImpl(localRepository: SocialLocalRepository, apiClient: ApiClient, userID: String) : BaseRepositoryImpl(localRepository, apiClient, userID), SocialRepository { + override fun getGroupMembership(id: String): Flowable { return localRepository.getGroupMembership(userID, id) } @@ -208,6 +206,10 @@ class SocialRepositoryImpl(localRepository: SocialLocalRepository, apiClient: Ap } else apiClient.getMemberWithUsername(username) } + override fun findUsernames(username: String, context: String?, id: String?): Flowable> { + return apiClient.findUsernames(username, context, id) + } + override fun markPrivateMessagesRead(user: User?): Flowable { return apiClient.markPrivateMessagesRead() .doOnNext { _ -> diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/models/social/FindUsernameResult.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/models/social/FindUsernameResult.kt new file mode 100644 index 000000000..c54f66c18 --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/models/social/FindUsernameResult.kt @@ -0,0 +1,24 @@ +package com.habitrpg.android.habitica.models.social + +import com.google.gson.annotations.SerializedName +import com.habitrpg.android.habitica.models.user.Authentication +import com.habitrpg.android.habitica.models.user.ContributorInfo +import io.realm.annotations.PrimaryKey + +class FindUsernameResult { + + @SerializedName("_id") + var id: String? = null + + var contributor: ContributorInfo? = null + var authentication: Authentication? = null + + val username: String? + get() = authentication?.localAuthentication?.username + val formattedUsername: String? + get() = if (username != null) "@$username" else null + + override fun toString(): String { + return authentication?.localAuthentication?.username ?: "" + } +} diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/AutocompleteAdapter.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/AutocompleteAdapter.kt new file mode 100644 index 000000000..15f116083 --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/AutocompleteAdapter.kt @@ -0,0 +1,64 @@ +package com.habitrpg.android.habitica.ui.helpers + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.Filter +import android.widget.Filterable +import android.widget.TextView +import com.habitrpg.android.habitica.R +import com.habitrpg.android.habitica.data.SocialRepository +import com.habitrpg.android.habitica.extensions.inflate +import com.habitrpg.android.habitica.models.social.FindUsernameResult +import com.habitrpg.android.habitica.ui.views.social.UsernameLabel + +class AutocompleteAdapter(val context: Context, val socialRepository: SocialRepository) : BaseAdapter(), Filterable { + private var results: List = arrayListOf() + + override fun getFilter(): Filter { + return object : Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + val filterResults = FilterResults() + if (constraint != null && constraint.isNotEmpty()) { + results = socialRepository.findUsernames(constraint.toString()).blockingFirst(arrayListOf()) + filterResults.values = results + filterResults.count = results.size + } + return filterResults + } + + override fun publishResults(contraint: CharSequence?, results: FilterResults?) { + if (results != null && results.count > 0) { + notifyDataSetChanged() + } else { + notifyDataSetInvalidated() + } + } + } + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { + val view = parent?.inflate(R.layout.autocomplete_username) + val result = getItem(position) + val displaynameView = view?.findViewById(R.id.display_name_view) + displaynameView?.username = result.username + displaynameView?.tier = result.contributor?.level ?: 0 + view?.findViewById(R.id.username_view)?.text = result.formattedUsername + return view ?: View(context) + } + + override fun getItem(position: Int): FindUsernameResult { + return results[position] + } + + override fun getItemId(position: Int): Long { + return getItem(position).id.hashCode().toLong() + } + + override fun getCount(): Int { + return results.size + } + + +} \ No newline at end of file diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/ChatInputTokenizer.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/ChatInputTokenizer.kt new file mode 100644 index 000000000..2fc519a2e --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/helpers/ChatInputTokenizer.kt @@ -0,0 +1,60 @@ +package com.habitrpg.android.habitica.ui.helpers + +import android.text.SpannableString +import android.text.Spanned +import android.text.TextUtils +import android.widget.MultiAutoCompleteTextView + + +class ChatInputTokenizer : MultiAutoCompleteTextView.Tokenizer { + + override fun findTokenStart(text: CharSequence, cursor: Int): Int { + var i = cursor + + while (i > 0 && text[i - 1] != '@') { + i-- + } + + //Check if token really started with @, else we don't have a valid token + return if (i < 1 || text[i - 1] != '@') { + cursor + } else i + + } + + override fun findTokenEnd(text: CharSequence, cursor: Int): Int { + var i = cursor + val len = text.length + + while (i < len) { + if (text[i] == ' ') { + return i + } else { + i++ + } + } + + return len + } + + override fun terminateToken(text: CharSequence): CharSequence { + var i = text.length + + while (i > 0 && text[i - 1] == ' ') { + i-- + } + + return if (i > 0 && text[i - 1] == ' ') { + text + } else { + if (text is Spanned) { + val sp = SpannableString(text.toString() + " ") + TextUtils.copySpansFrom(text, 0, text.length, + Any::class.java, sp, 0) + sp + } else { + text.toString() + " " + } + } + } +} \ No newline at end of file diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/social/ChatBarView.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/social/ChatBarView.kt index 0b011ed09..2020e7cbd 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/social/ChatBarView.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/social/ChatBarView.kt @@ -2,8 +2,6 @@ package com.habitrpg.android.habitica.ui.views.social import android.content.Context import android.os.Build -import androidx.core.content.ContextCompat -import androidx.appcompat.widget.AppCompatEditText import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet @@ -12,18 +10,26 @@ import android.view.LayoutInflater import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.* +import androidx.core.content.ContextCompat import androidx.core.view.updateLayoutParams +import com.habitrpg.android.habitica.HabiticaBaseApplication import com.habitrpg.android.habitica.R +import com.habitrpg.android.habitica.data.SocialRepository +import com.habitrpg.android.habitica.ui.helpers.AutocompleteAdapter +import com.habitrpg.android.habitica.ui.helpers.ChatInputTokenizer import com.habitrpg.android.habitica.ui.helpers.NavbarUtils import com.habitrpg.android.habitica.ui.helpers.bindView -import net.pherth.android.emoji_library.EmojiEditText import net.pherth.android.emoji_library.EmojiPopup -import net.pherth.android.emoji_library.EmojiTextView +import javax.inject.Inject + class ChatBarView : FrameLayout { + @Inject + lateinit var socialRepository: SocialRepository + private val sendButton: ImageButton by bindView(R.id.sendButton) - private val chatEditText: EmojiEditText by bindView(R.id.chatEditText) + private val chatEditText: MultiAutoCompleteTextView by bindView(R.id.chatEditText) private val textIndicator: TextView by bindView(R.id.text_indicator) private val indicatorSpacing: View by bindView(R.id.indicator_spacing) private val emojiButton: ImageButton by bindView(R.id.emojiButton) @@ -50,6 +56,8 @@ class ChatBarView : FrameLayout { inflater?.inflate(R.layout.tavern_chat_new_entry_item, this) this.setBackgroundResource(R.color.white) + HabiticaBaseApplication.component?.inject(this) + chatEditText.addTextChangedListener(object: TextWatcher { override fun afterTextChanged(s: Editable?) {} @@ -98,6 +106,12 @@ class ChatBarView : FrameLayout { chatEditText.dispatchKeyEvent(event) } resizeForDrawingUnderNavbar() + + val tagArray = AutocompleteAdapter(context, socialRepository) + chatEditText.setAdapter(tagArray) + chatEditText.threshold = 2 + + chatEditText.setTokenizer(ChatInputTokenizer()) } private fun updateTextIndicator(text: String) { @@ -166,7 +180,7 @@ class ChatBarView : FrameLayout { } } - private inner class EmojiClickListener internal constructor(internal var view: EmojiEditText) : View.OnClickListener { + private inner class EmojiClickListener internal constructor(internal var view: MultiAutoCompleteTextView) : View.OnClickListener { override fun onClick(v: View) { if (!popup.isShowing) { diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/utils/FindUsernameResultDeserializer.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/utils/FindUsernameResultDeserializer.kt new file mode 100644 index 000000000..bef613db6 --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/utils/FindUsernameResultDeserializer.kt @@ -0,0 +1,28 @@ +package com.habitrpg.android.habitica.utils + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.habitrpg.android.habitica.models.social.FindUsernameResult +import com.habitrpg.android.habitica.models.user.Authentication +import com.habitrpg.android.habitica.models.user.ContributorInfo +import java.lang.reflect.Type + +class FindUsernameResultDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): FindUsernameResult { + val obj = json.asJsonObject + + val result = FindUsernameResult() + + if (obj.has("contributor")) { + result.contributor = context.deserialize(obj.get("contributor"), ContributorInfo::class.java) + } + if (obj.has("auth")) { + result.authentication = context.deserialize(obj.get("auth"), Authentication::class.java) + } + + return result + } +} \ No newline at end of file