Begin implementing username autocomplete

This commit is contained in:
Phillip Thelen 2019-02-27 15:43:53 +01:00
parent d6d43e7fda
commit 67c6d5675e
14 changed files with 240 additions and 15 deletions

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:layout_marginBottom="@dimen/spacing_medium"
android:layout_marginStart="@dimen/spacing_large"
android:layout_marginEnd="@dimen/spacing_large">
<com.habitrpg.android.habitica.ui.views.social.UsernameLabel
android:id="@+id/display_name_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/username_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -34,7 +34,7 @@
app:hintEnabled="false"
android:layout_marginBottom="@dimen/spacing_medium"
android:layout_marginTop="@dimen/spacing_medium">
<net.pherth.android.emoji_library.EmojiEditText
<MultiAutoCompleteTextView
android:id="@+id/chatEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -27,6 +27,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;
@ -287,6 +288,9 @@ public interface ApiService {
@POST("members/send-private-message")
Flowable<HabitResponse<PostChatMessageResult>> postPrivateMessage(@Body Map<String, String> messageDetails);
@GET("members/find/{username}")
Flowable<HabitResponse<List<FindUsernameResult>>> findUsernames(@Path("username") String username, @Query("context") String context, @Query("id") String id);
@GET("shops/{identifier}")
Flowable<HabitResponse<Shop>> retrieveShopInventory(@Path("identifier") String identifier);
@GET("shops/market-gear")

View file

@ -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();

View file

@ -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);
}

View file

@ -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<Shop>
fun verifyUsername(username: String): Flowable<VerifyUsernameResponse>
fun updateServerUrl(newAddress: String?)
fun findUsernames(username: String, context: String?, id: String?): Flowable<List<FindUsernameResult>>
}

View file

@ -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<Member>
fun getMemberWithUsername(username: String?): Flowable<Member>
fun findUsernames(username: String, context: String? = null, id: String? = null): Flowable<List<FindUsernameResult>>
fun markPrivateMessagesRead(user: User?): Flowable<Void>
fun acceptQuest(user: User?, partyId: String): Flowable<Void>

View file

@ -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<List<FindUsernameResult>> {
return apiService.findUsernames(username, context, id).compose(configureApiCallObserver())
}
override fun postPrivateMessage(messageDetails: Map<String, String>): Flowable<PostChatMessageResult> {
return apiService.postPrivateMessage(messageDetails).compose(configureApiCallObserver())
}

View file

@ -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<SocialLocalRepository>(localRepository, apiClient, userID), SocialRepository {
override fun getGroupMembership(id: String): Flowable<GroupMembership> {
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<List<FindUsernameResult>> {
return apiClient.findUsernames(username, context, id)
}
override fun markPrivateMessagesRead(user: User?): Flowable<Void> {
return apiClient.markPrivateMessagesRead()
.doOnNext { _ ->

View file

@ -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 ?: ""
}
}

View file

@ -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<FindUsernameResult> = 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<UsernameLabel>(R.id.display_name_view)
displaynameView?.username = result.username
displaynameView?.tier = result.contributor?.level ?: 0
view?.findViewById<TextView>(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
}
}

View file

@ -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() + " "
}
}
}
}

View file

@ -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) {

View file

@ -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<FindUsernameResult> {
@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<ContributorInfo>(obj.get("contributor"), ContributorInfo::class.java)
}
if (obj.has("auth")) {
result.authentication = context.deserialize<Authentication>(obj.get("auth"), Authentication::class.java)
}
return result
}
}