diff --git a/app/build.gradle b/app/build.gradle index ca74bfe4fc748b091c1ef9044d9055cf689072a5..5bcdd1181824e18f84ae10c9487e7a729fece6e1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -77,7 +77,7 @@ dependencies { implementation project(path: ':gallery') //Firebase - gplayImplementation platform('com.google.firebase:firebase-bom:32.3.1') + gplayImplementation platform('com.google.firebase:firebase-bom:32.4.0') gplayImplementation 'com.google.firebase:firebase-crashlytics-ktx' gplayImplementation 'com.google.firebase:firebase-analytics-ktx' gplayImplementation 'com.google.firebase:firebase-messaging-ktx' @@ -95,9 +95,6 @@ dependencies { implementation "io.noties.markwon:ext-strikethrough:$markwon_version" implementation "io.noties.markwon:ext-tasklist:$markwon_version" - //Mentions autocomplete - implementation "com.otaliastudios:autocomplete:1.1.0" - //Log implementation 'com.jakewharton.timber:timber:5.0.1' diff --git a/app/src/main/java/org/futo/circles/feature/notifications/NotificationBitmapLoader.kt b/app/src/main/java/org/futo/circles/feature/notifications/NotificationBitmapLoader.kt index 77617fef7f1abd34b3d6dd5572c08c1ed51dc8f9..4940b8fbf8989ae598b102a28462168b596d38c5 100644 --- a/app/src/main/java/org/futo/circles/feature/notifications/NotificationBitmapLoader.kt +++ b/app/src/main/java/org/futo/circles/feature/notifications/NotificationBitmapLoader.kt @@ -6,13 +6,13 @@ import android.graphics.Color import android.os.Build import androidx.annotation.WorkerThread import androidx.core.graphics.drawable.IconCompat -import com.amulyakhare.textdrawable.TextDrawable -import com.amulyakhare.textdrawable.util.ColorGenerator import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.bumptech.glide.signature.ObjectKey import dagger.hilt.android.qualifiers.ApplicationContext +import org.futo.circles.core.feature.textDrawable.ColorGenerator +import org.futo.circles.core.feature.textDrawable.TextDrawable import javax.inject.Inject class NotificationBitmapLoader @Inject constructor( @@ -23,7 +23,7 @@ class NotificationBitmapLoader @Inject constructor( fun getRoomBitmap(roomName: String, path: String?): Bitmap { val placeholder = TextDrawable.Builder() .setShape(TextDrawable.SHAPE_RECT) - .setColor(ColorGenerator.DEFAULT.getColor(roomName)) + .setColor(ColorGenerator().getColor(roomName)) .setTextColor(Color.WHITE) .setWidth(64) .setHeight(64) @@ -40,7 +40,7 @@ class NotificationBitmapLoader @Inject constructor( .submit() .get() } catch (e: Exception) { - placeholder.bitmap + placeholder.getBitmap() } } diff --git a/app/src/main/java/org/futo/circles/feature/timeline/post/markdown/mentions/MentionsPresenter.kt b/app/src/main/java/org/futo/circles/feature/timeline/post/markdown/mentions/MentionsPresenter.kt index e269d0d5127f9f300fb50409ae22e86a12ea67e3..1024ed548fd3ea7fbf907c31a5ba17a348474af7 100644 --- a/app/src/main/java/org/futo/circles/feature/timeline/post/markdown/mentions/MentionsPresenter.kt +++ b/app/src/main/java/org/futo/circles/feature/timeline/post/markdown/mentions/MentionsPresenter.kt @@ -2,7 +2,7 @@ package org.futo.circles.feature.timeline.post.markdown.mentions import android.content.Context import androidx.recyclerview.widget.RecyclerView -import com.otaliastudios.autocomplete.RecyclerViewPresenter +import org.futo.circles.core.feature.autocomplete.RecyclerViewPresenter import org.futo.circles.core.mapping.toUserListItem import org.futo.circles.core.model.UserListItem import org.futo.circles.core.provider.MatrixSessionProvider diff --git a/app/src/main/java/org/futo/circles/view/MarkdownEditText.kt b/app/src/main/java/org/futo/circles/view/MarkdownEditText.kt index 72760f9b40c7eb43e47775edba4cdea5e3b879a7..04efcc95af6916f148330cf1d739c8bc0df3a913 100644 --- a/app/src/main/java/org/futo/circles/view/MarkdownEditText.kt +++ b/app/src/main/java/org/futo/circles/view/MarkdownEditText.kt @@ -14,9 +14,6 @@ import android.view.View import androidx.appcompat.widget.AppCompatEditText import androidx.core.content.ContextCompat import androidx.core.widget.doOnTextChanged -import com.otaliastudios.autocomplete.Autocomplete -import com.otaliastudios.autocomplete.AutocompleteCallback -import com.otaliastudios.autocomplete.CharPolicy import io.noties.markwon.LinkResolverDef import io.noties.markwon.Markwon import io.noties.markwon.core.spans.BulletListItemSpan @@ -26,6 +23,9 @@ import io.noties.markwon.core.spans.StrongEmphasisSpan import io.noties.markwon.ext.tasklist.TaskListDrawable import io.noties.markwon.ext.tasklist.TaskListSpan import org.futo.circles.R +import org.futo.circles.core.feature.autocomplete.Autocomplete +import org.futo.circles.core.feature.autocomplete.AutocompleteCallback +import org.futo.circles.core.feature.autocomplete.CharPolicy import org.futo.circles.core.model.UserListItem import org.futo.circles.extensions.getGivenSpansAt import org.futo.circles.feature.timeline.post.markdown.EnhancedMovementMethod diff --git a/build.gradle b/build.gradle index 17677ac2f1c87383f8a85877a9fb27acc77d70d6..c48ef9250e683fe5ac17f602278906aee17cbc96 100644 --- a/build.gradle +++ b/build.gradle @@ -2,8 +2,8 @@ buildscript { ext { sdk_version = 34 min_sdk_version = 24 - androidx_nav_version = '2.7.3' - hilt_version = '2.48' + androidx_nav_version = '2.7.4' + hilt_version = '2.48.1' modules_version = "1.0.9" modules_gitlab_projectId = "13" } @@ -28,9 +28,6 @@ allprojects { maven { url 'https://jitpack.io' } maven { url 'https://gitlab.futo.org/api/v4/projects/16/packages/maven' } maven { url 'https://gitlab.futo.org/api/v4/projects/13/packages/maven' } - //Added for mentions dialog popup lib com.otaliastudios:autocomplete:1.1.0 - //noinspection JcenterRepositoryObsolete - jcenter() } } diff --git a/core/build.gradle b/core/build.gradle index 06761d38d9f53b2e94e9f18504f435a5f95fc660..e085b93d6d794ab52e6cc97978e9139ee06ba3c0 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -44,8 +44,8 @@ android { dependencies { api 'androidx.appcompat:appcompat:1.6.1' - api 'com.google.android.material:material:1.9.0' - api 'androidx.recyclerview:recyclerview:1.3.1' + api 'com.google.android.material:material:1.10.0' + api 'androidx.recyclerview:recyclerview:1.3.2' api "androidx.autofill:autofill:1.1.0" //Kotlin @@ -79,9 +79,6 @@ dependencies { //Gson api 'com.google.code.gson:gson:2.10.1' - //TextDrawable - api 'com.alvinhkh:TextDrawable:c1c2b5b' - //Worker def work_version = "2.8.1" api "androidx.work:work-runtime-ktx:$work_version" diff --git a/core/src/main/java/org/futo/circles/core/extensions/ImageViewExtensions.kt b/core/src/main/java/org/futo/circles/core/extensions/ImageViewExtensions.kt index 7152ce21d21a4839f9aebd548a446d24d6d5edef..e10c63918c1c63bd18040b4d855da7dee6a46a10 100644 --- a/core/src/main/java/org/futo/circles/core/extensions/ImageViewExtensions.kt +++ b/core/src/main/java/org/futo/circles/core/extensions/ImageViewExtensions.kt @@ -5,11 +5,11 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.util.Size import android.widget.ImageView -import com.amulyakhare.textdrawable.TextDrawable -import com.amulyakhare.textdrawable.util.ColorGenerator import com.bumptech.glide.Glide import com.bumptech.glide.request.target.Target import org.futo.circles.core.feature.blurhash.ThumbHash +import org.futo.circles.core.feature.textDrawable.ColorGenerator +import org.futo.circles.core.feature.textDrawable.TextDrawable import org.futo.circles.core.glide.GlideApp import org.futo.circles.core.model.MediaFileData import org.futo.circles.core.provider.MatrixSessionProvider @@ -57,8 +57,7 @@ fun ImageView.loadProfileIcon( preferredSize: Size? = null, session: Session? = null ) { - - val backgroundColor = ColorGenerator.DEFAULT.getColor(userId) + val backgroundColor = ColorGenerator().getColor(userId) var text = userId.firstOrNull()?.toString()?.uppercase() ?: "" if (text == "@") text = userId.elementAtOrNull(1)?.toString()?.uppercase() ?: "?" val placeholder = TextDrawable.Builder() diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/Autocomplete.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/Autocomplete.kt new file mode 100644 index 0000000000000000000000000000000000000000..e9724b73e290e8b65715b5a3fbaa30d94b5c77a0 --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/Autocomplete.kt @@ -0,0 +1,415 @@ +package org.futo.circles.core.feature.autocomplete + +import android.database.DataSetObserver +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.Selection +import android.text.SpanWatcher +import android.text.Spannable +import android.text.SpannableString +import android.text.TextWatcher +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.widget.EditText +import android.widget.PopupWindow +import org.futo.circles.core.feature.autocomplete.Autocomplete.SimplePolicy + +/** + * Entry point for adding Autocomplete behavior to a [EditText]. + * + * You can construct a `Autocomplete` using the builder provided by [Autocomplete.on]. + * Building is enough, but you can hold a reference to this class to call its public methods. + * + * Requires: + * - [EditText]: this is both the anchor for the popup, and the source of text events that we listen to + * - [AutocompletePresenter]: this presents items in the popup window. See class for more info. + * - [AutocompleteCallback]: if specified, this listens to click events and visibility changes + * - [AutocompletePolicy]: if specified, this controls how and when to show the popup based on text events + * If not, this defaults to [SimplePolicy]: shows the popup when text.length() bigger than 0. + */ +class Autocomplete<T> private constructor(builder: Builder<T>) : TextWatcher, + SpanWatcher { + /** + * Builder for building [Autocomplete]. + * The only mandatory item is a presenter, [.with]. + * + * @param <T> the data model + </T> */ + class Builder<T>(source: EditText) { + internal var source: EditText? + internal var presenter: AutocompletePresenter<T>? = null + internal var policy: AutocompletePolicy? = null + internal var callback: AutocompleteCallback<T>? = null + internal var backgroundDrawable: Drawable? = null + internal var elevationDp = 6f + + init { + this.source = source + } + + /** + * Registers the [AutocompletePresenter] to be used, responsible for showing + * items. See the class for info. + * + * @param presenter desired presenter + * @return this for chaining + */ + fun with(presenter: AutocompletePresenter<T>): Builder<T> { + this.presenter = presenter + return this + } + + /** + * Registers the [AutocompleteCallback] to be used, responsible for listening to + * clicks provided by the presenter, and visibility changes. + * + * @param callback desired callback + * @return this for chaining + */ + fun with(callback: AutocompleteCallback<T>): Builder<T> { + this.callback = callback + return this + } + + /** + * Registers the [AutocompletePolicy] to be used, responsible for showing / dismissing + * the popup when certain events happen (e.g. certain characters are typed). + * + * @param policy desired policy + * @return this for chaining + */ + fun with(policy: AutocompletePolicy?): Builder<T> { + this.policy = policy + return this + } + + /** + * Sets a background drawable for the popup. + * + * @param backgroundDrawable drawable + * @return this for chaining + */ + fun with(backgroundDrawable: Drawable?): Builder<T> { + this.backgroundDrawable = backgroundDrawable + return this + } + + /** + * Sets elevation for the popup. Defaults to 6 dp. + * + * @param elevationDp popup elevation, in DP + * @return this for chaning. + */ + fun with(elevationDp: Float): Builder<T> { + this.elevationDp = elevationDp + return this + } + + /** + * Builds an Autocomplete instance. This is enough for autocomplete to be set up, + * but you can hold a reference to the object and call its public methods. + * + * @return an Autocomplete instance, if you need it + * + * @throws RuntimeException if either EditText or the presenter are null + */ + fun build(): Autocomplete<T> { + if (source == null) throw RuntimeException("Autocomplete needs a source!") + if (presenter == null) throw RuntimeException("Autocomplete needs a presenter!") + if (policy == null) policy = SimplePolicy() + return Autocomplete(this) + } + + fun clear() { + source = null + presenter = null + callback = null + policy = null + backgroundDrawable = null + elevationDp = 6f + } + } + + private val policy: AutocompletePolicy + private val popup: AutocompletePopup + private val presenter: AutocompletePresenter<T> + private val callback: AutocompleteCallback<T>? + private val source: EditText + private var block = false + private var disabled = false + private var openBefore = false + private var lastQuery = "null" + + init { + policy = builder.policy!! + presenter = builder.presenter!! + callback = builder.callback + source = builder.source!! + + // Set up popup + popup = AutocompletePopup(source.context) + popup.setAnchorView(source) + popup.setGravity(Gravity.START) + popup.isModal = false + popup.setBackgroundDrawable(builder.backgroundDrawable) + popup.setElevation( + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, builder.elevationDp, + source.context.resources.displayMetrics + ) + ) + + // popup dimensions + val dim: AutocompletePresenter.PopupDimensions = presenter.popupDimensions + popup.width = dim.width + popup.height = dim.height + popup.setMaxWidth(dim.maxWidth) + popup.setMaxHeight(dim.maxHeight) + + // Fire visibility events + popup.setOnDismissListener(PopupWindow.OnDismissListener { + lastQuery = "null" + callback?.onPopupVisibilityChanged(false) + val saved = block + block = true + policy.onDismiss(source.text) + block = saved + presenter.hideView() + }) + + // Set up source + source.text.setSpan(this, 0, source.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE) + source.addTextChangedListener(this) + + // Set up presenter + presenter.registerClickProvider(object : AutocompletePresenter.ClickProvider<T> { + override fun click(item: T) { + val callback: AutocompleteCallback<T>? = callback + val edit = source + if (callback == null) return + val saved = block + block = true + val dismiss: Boolean = callback.onPopupItemClicked(edit.text, item) + if (dismiss) dismissPopup() + block = saved + } + }) + builder.clear() + } + + /** + * Controls how the popup operates with an input method. + * + * If the popup is showing, calling this method will take effect only + * the next time the popup is shown. + * + * @param mode a [PopupWindow] input method mode + */ + fun setInputMethodMode(mode: Int) { + popup.setInputMethodMode(mode) + } + + /** + * Sets the operating mode for the soft input area. + * + * @param mode The desired mode, see [WindowManager.LayoutParams.softInputMode] + */ + fun setSoftInputMode(mode: Int) { + popup.softInputMode = mode + } + + /** + * Shows the popup with the given query. + * There is rarely need to call this externally: it is already triggered by events on the anchor. + * To control when this is called, provide a good implementation of [AutocompletePolicy]. + * + * @param query query text. + */ + fun showPopup(query: CharSequence) { + if (isPopupShowing && lastQuery == query.toString()) return + lastQuery = query.toString() + log("showPopup: called with filter $query") + if (!isPopupShowing) { + log("showPopup: showing") + presenter.registerDataSetObserver(Observer()) // Calling new to avoid leaking... maybe... + popup.setView(presenter.view) + presenter.showView() + popup.show() + callback?.onPopupVisibilityChanged(true) + } + log("showPopup: popup should be showing... $isPopupShowing") + presenter.onQuery(query) + } + + /** + * Dismisses the popup, if showing. + * There is rarely need to call this externally: it is already triggered by events on the anchor. + * To control when this is called, provide a good implementation of [AutocompletePolicy]. + */ + fun dismissPopup() { + if (isPopupShowing) { + popup.dismiss() + } + } + + val isPopupShowing: Boolean + /** + * Returns true if the popup is showing. + * @return whether the popup is currently showing + */ + get() = popup.isShowing + + /** + * Switch to control the autocomplete behavior. When disabled, no popup is shown. + * This is useful if you want to do runtime edits to the anchor text, without triggering + * the popup. + * + * @param enabled whether to enable autocompletion + */ + fun setEnabled(enabled: Boolean) { + disabled = !enabled + } + + /** + * Sets the gravity for the popup. Basically only [Gravity.START] and [Gravity.END] + * do work. + * + * @param gravity gravity for the popup + */ + fun setGravity(gravity: Int) { + popup.setGravity(gravity) + } + + /** + * Controls the vertical offset of the popup from the EditText anchor. + * + * @param offset offset in pixels. + */ + fun setOffsetFromAnchor(offset: Int) { + popup.setVerticalOffset(offset) + } + + /** + * Controls whether the popup should listen to clicks outside its boundaries. + * + * @param outsideTouchable true to listen to outside clicks + */ + fun setOutsideTouchable(outsideTouchable: Boolean) { + popup.isOutsideTouchable = outsideTouchable + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + if (block || disabled) return + openBefore = isPopupShowing + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + if (block || disabled) return + if (openBefore && !isPopupShowing) { + return // Copied from somewhere. + } + if (s !is Spannable) { + source.setText(SpannableString(s)) + return + } + val sp = s + val cursor = source.selectionEnd + log("onTextChanged: cursor end position is $cursor") + if (cursor == -1) { // No cursor present. + dismissPopup() + return + } + val b = block + block = true // policy might add spans or other stuff. + if (isPopupShowing && policy.shouldDismissPopup(sp, cursor)) { + log("onTextChanged: dismissing") + dismissPopup() + } else if (isPopupShowing || policy.shouldShowPopup(sp, cursor)) { + // LOG.now("onTextChanged: updating with filter "+policy.getQuery(sp)); + showPopup(policy.getQuery(sp)) + } + block = b + } + + override fun afterTextChanged(s: Editable) {} + override fun onSpanAdded(text: Spannable, what: Any, start: Int, end: Int) {} + override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) {} + override fun onSpanChanged( + text: Spannable, + what: Any, + ostart: Int, + oend: Int, + nstart: Int, + nend: Int + ) { + if (disabled || block) return + if (what === Selection.SELECTION_END) { + // Selection end changed from ostart to nstart. Trigger a check. + log("onSpanChanged: selection end moved from $ostart to $nstart") + log("onSpanChanged: block is $block") + val b = block + block = true + if (!isPopupShowing && policy.shouldShowPopup(text, nstart)) { + showPopup(policy.getQuery(text)) + } + block = b + } + } + + private inner class Observer : DataSetObserver(), Runnable { + private val ui = Handler(Looper.getMainLooper()) + override fun onChanged() { + // ??? Not sure this is needed... + ui.post(this) + } + + override fun run() { + if (isPopupShowing) { + // Call show again to revisit width and height. + popup.show() + } + } + } + + /** + * A very simple [AutocompletePolicy] implementation. + * Popup is shown when text length is bigger than 0, and hidden when text is empty. + * The query string is the whole text. + */ + class SimplePolicy : AutocompletePolicy { + override fun shouldShowPopup(text: Spannable, cursorPos: Int): Boolean { + return text.length > 0 + } + + override fun shouldDismissPopup(text: Spannable, cursorPos: Int): Boolean { + return text.length == 0 + } + + override fun getQuery(text: Spannable): CharSequence { + return text + } + + override fun onDismiss(text: Spannable) {} + } + + companion object { + private val TAG = Autocomplete::class.java.simpleName + private const val DEBUG = false + private fun log(log: String) { + if (DEBUG) Log.e(TAG, log) + } + + /** + * Entry point for building autocomplete on a certain [EditText]. + * @param anchor the anchor for the popup, and the source of text events + * @param <T> your data model + * @return a Builder for set up + </T> */ + fun <T> on(anchor: EditText): Builder<T> { + return Builder<T>(anchor) + } + } +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompleteCallback.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompleteCallback.kt new file mode 100644 index 0000000000000000000000000000000000000000..85077087f5272f03bbbbc22ce3507a133b6af9a2 --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompleteCallback.kt @@ -0,0 +1,26 @@ +package org.futo.circles.core.feature.autocomplete + +import android.text.Editable + +/** + * Optional callback to be passed to [Autocomplete.Builder]. + */ +interface AutocompleteCallback<T> { + /** + * Called when an item inside your list is clicked. + * This works if your presenter has dispatched a click event. + * At this point you can edit the text, e.g. `editable.append(item.toString())`. + * + * @param editable editable text that you can work on + * @param item item that was clicked + * @return true if the action is valid and the popup can be dismissed + */ + fun onPopupItemClicked(editable: Editable, item: T): Boolean + + /** + * Called when popup visibility state changes. + * + * @param shown true if the popup was just shown, false if it was just hidden + */ + fun onPopupVisibilityChanged(shown: Boolean) +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePolicy.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePolicy.kt new file mode 100644 index 0000000000000000000000000000000000000000..c21271109a465f5be2fc27a933cad9d451801a5e --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePolicy.kt @@ -0,0 +1,60 @@ +package org.futo.circles.core.feature.autocomplete + +import android.text.Spannable + +/** + * This interface controls when to show or hide the popup window, and, in the first case, + * what text should be passed to the popup [AutocompletePresenter]. + * + * @see Autocomplete.SimplePolicy for the simplest possible implementation + */ +interface AutocompletePolicy { + /** + * Called to understand whether the popup should be shown. Some naive examples: + * - Show when there's text: `return text.length() > 0` + * - Show when last char is @: `return text.getCharAt(text.length()-1) == '@'` + * + * @param text current text, along with its Spans + * @param cursorPos the position of the cursor + * @return true if popup should be shown + */ + fun shouldShowPopup(text: Spannable, cursorPos: Int): Boolean + + /** + * Called to understand whether a currently shown popup should be closed, maybe + * because text is invalid. A reasonable implementation is + * `return !shouldShowPopup(text, cursorPos)`. + * + * However this is defined so you can add or clear spans. + * + * @param text current text, along with its Spans + * @param cursorPos the position of the cursor + * @return true if popup should be hidden + */ + fun shouldDismissPopup(text: Spannable, cursorPos: Int): Boolean + + /** + * Called to understand which query should be passed to [AutocompletePresenter] + * for a showing popup. If this is called, [.shouldShowPopup] just returned + * true, or [.shouldDismissPopup] just returned false. + * + * This is useful to understand which part of the text should be passed to presenters. + * For example, user might have typed '@john' to select a username, but you just want to + * search for 'john'. + * + * For more complex cases, you can add inclusive Spans in [.shouldShowPopup], + * and get the span position here. + * + * @param text current text, along with its Spans + * @return the query for presenter + */ + fun getQuery(text: Spannable): CharSequence + + /** + * Called when popup is dismissed. This can be used, for instance, to clear custom Spans + * from the text. + * + * @param text text at the moment of dismissing + */ + fun onDismiss(text: Spannable) +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePopup.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePopup.kt new file mode 100644 index 0000000000000000000000000000000000000000..cd239fb63d1b3e21d6dbb7b6a53d4c39ae1d25e1 --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePopup.kt @@ -0,0 +1,476 @@ +package org.futo.circles.core.feature.autocomplete + +import android.content.Context +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.PopupWindow +import androidx.annotation.StyleRes +import androidx.core.view.ViewCompat +import androidx.core.widget.PopupWindowCompat +import kotlin.math.min + +/** + * A simplified version of andriod.widget.ListPopupWindow, which is the class used by + * AutocompleteTextView. + * + * Other than being simplified, this deals with Views rather than ListViews, so the content + * can be whatever. Lots of logic (clicks, selections etc.) has been removed because we manage that + * in [AutocompletePresenter]. + * + */ +internal class AutocompletePopup(private val mContext: Context) { + private var mView: ViewGroup? = null + /** + * @return The height of the popup window in pixels. + */ + /** + * Sets the height of the popup window in pixels. Can also be MATCH_PARENT. + * @param height Height of the popup window. + */ + @get:Suppress("unused") + var height = ViewGroup.LayoutParams.WRAP_CONTENT + /** + * @return The width of the popup window in pixels. + */ + /** + * Sets the width of the popup window in pixels. Can also be MATCH_PARENT + * or WRAP_CONTENT. + * @param width Width of the popup window. + */ + @get:Suppress("unused") + var width = ViewGroup.LayoutParams.WRAP_CONTENT + private var mMaxHeight = Int.MAX_VALUE + private var mMaxWidth = Int.MAX_VALUE + private var mUserMaxHeight = Int.MAX_VALUE + private var mUserMaxWidth = Int.MAX_VALUE + private var mHorizontalOffset = 0 + private var mVerticalOffset = 0 + private var mVerticalOffsetSet = false + private var mGravity = Gravity.NO_GRAVITY + /** + * @return Whether the drop-down is visible under special conditions. + */ + /** + * Sets whether the drop-down should remain visible under certain conditions. + * + * The drop-down will occupy the entire screen below [.getAnchorView] regardless + * of the size or content of the list. [.getBackground] will fill any space + * that is not used by the list. + * @param dropDownAlwaysVisible Whether to keep the drop-down visible. + */ + @get:Suppress("unused") + @set:Suppress("unused") + var isDropDownAlwaysVisible = false + private var mOutsideTouchable = true + + /** + * Returns the view that will be used to anchor this popup. + * @return The popup's anchor view + */ + var anchorView: View? = null + private set + private val mTempRect = Rect() + private var mModal = false + private val mPopup: PopupWindow = PopupWindow(mContext) + + /** + * Create a new, empty popup window capable of displaying items from a ListAdapter. + * Backgrounds should be set using [.setBackgroundDrawable]. + * + * @param context Context used for contained views. + */ + init { + mPopup.inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED + } + + @get:Suppress("unused") + var isModal: Boolean + /** + * Returns whether the popup window will be modal when shown. + * @return `true` if the popup window will be modal, `false` otherwise. + */ + get() = mModal + /** + * Set whether this window should be modal when shown. + * + * + * If a popup window is modal, it will receive all touch and key input. + * If the user touches outside the popup window's content area the popup window + * will be dismissed. + * @param modal `true` if the popup window should be modal, `false` otherwise. + */ + set(modal) { + mModal = modal + mPopup.isFocusable = modal + } + + fun setElevation(elevationPx: Float) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) mPopup.elevation = elevationPx + } + + var isOutsideTouchable: Boolean + get() = mOutsideTouchable && !isDropDownAlwaysVisible + set(outsideTouchable) { + mOutsideTouchable = outsideTouchable + } + + @get:Suppress("unused") + var softInputMode: Int + /** + * Returns the current value in [.setSoftInputMode]. + * @see .setSoftInputMode + * @see android.view.WindowManager.LayoutParams.softInputMode + */ + get() = mPopup.softInputMode + /** + * Sets the operating mode for the soft input area. + * @param mode The desired mode, see + * [android.view.WindowManager.LayoutParams.softInputMode] + * for the full list + * @see android.view.WindowManager.LayoutParams.softInputMode + * + * @see .getSoftInputMode + */ + set(mode) { + mPopup.softInputMode = mode + } + + @get:Suppress("unused") + val background: Drawable? + /** + * @return The background drawable for the popup window. + */ + get() = mPopup.background + + /** + * Sets a drawable to be the background for the popup window. + * @param d A drawable to set as the background. + */ + fun setBackgroundDrawable(d: Drawable?) { + mPopup.setBackgroundDrawable(d) + } + + @get:StyleRes + @get:Suppress("unused") + @set:Suppress("unused") + var animationStyle: Int + /** + * Returns the animation style that will be used when the popup window is + * shown or dismissed. + * @return Animation style that will be used. + */ + get() = mPopup.animationStyle + /** + * Set an animation style to use when the popup window is shown or dismissed. + * @param animationStyle Animation style to use. + */ + set(animationStyle) { + mPopup.animationStyle = animationStyle + } + + /** + * Sets the popup's anchor view. This popup will always be positioned relative to + * the anchor view when shown. + * @param anchor The view to use as an anchor. + */ + fun setAnchorView(anchor: View) { + anchorView = anchor + } + + /** + * Set the horizontal offset of this popup from its anchor view in pixels. + * @param offset The horizontal offset of the popup from its anchor. + */ + @Suppress("unused") + fun setHorizontalOffset(offset: Int) { + mHorizontalOffset = offset + } + + /** + * Set the vertical offset of this popup from its anchor view in pixels. + * @param offset The vertical offset of the popup from its anchor. + */ + fun setVerticalOffset(offset: Int) { + mVerticalOffset = offset + mVerticalOffsetSet = true + } + + /** + * Set the gravity of the dropdown list. This is commonly used to + * set gravity to START or END for alignment with the anchor. + * @param gravity Gravity value to use + */ + fun setGravity(gravity: Int) { + mGravity = gravity + } + + /** + * Sets the width of the popup window by the size of its content. The final width may be + * larger to accommodate styled window dressing. + * @param width Desired width of content in pixels. + */ + @Suppress("unused") + fun setContentWidth(contentWidth: Int) { + var width = contentWidth + val popupBackground = mPopup.background + if (popupBackground != null) { + popupBackground.getPadding(mTempRect) + width += mTempRect.left + mTempRect.right + } + this.width = width + } + + fun setMaxWidth(width: Int) { + if (width > 0) { + mUserMaxWidth = width + } + } + + /** + * Sets the height of the popup window by the size of its content. The final height may be + * larger to accommodate styled window dressing. + * @param height Desired height of content in pixels. + */ + @Suppress("unused") + fun setContentHeight(contentHeight: Int) { + var height = contentHeight + val popupBackground = mPopup.background + if (popupBackground != null) { + popupBackground.getPadding(mTempRect) + height += mTempRect.top + mTempRect.bottom + } + this.height = height + } + + fun setMaxHeight(height: Int) { + if (height > 0) { + mUserMaxHeight = height + } + } + + fun setOnDismissListener(listener: PopupWindow.OnDismissListener?) { + mPopup.setOnDismissListener(listener) + } + + /** + * Show the popup list. If the list is already showing, this method + * will recalculate the popup's size and position. + */ + fun show() { + if (!ViewCompat.isAttachedToWindow(anchorView!!)) return + val height = buildDropDown() + val noInputMethod = isInputMethodNotNeeded + val mDropDownWindowLayoutType = WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL + PopupWindowCompat.setWindowLayoutType(mPopup, mDropDownWindowLayoutType) + if (mPopup.isShowing) { + // First pass for this special case, don't know why. + if (this.height == ViewGroup.LayoutParams.MATCH_PARENT) { + val tempWidth = + if (width == ViewGroup.LayoutParams.MATCH_PARENT) ViewGroup.LayoutParams.MATCH_PARENT else 0 + if (noInputMethod) { + mPopup.width = tempWidth + mPopup.height = 0 + } else { + mPopup.width = tempWidth + mPopup.height = ViewGroup.LayoutParams.MATCH_PARENT + } + } + + // The call to PopupWindow's update method below can accept -1 + // for any value you do not want to update. + + // Width. + var widthSpec: Int + widthSpec = if (width == ViewGroup.LayoutParams.MATCH_PARENT) { + -1 + } else if (width == ViewGroup.LayoutParams.WRAP_CONTENT) { + anchorView!!.width + } else { + width + } + widthSpec = Math.min(widthSpec, mMaxWidth) + widthSpec = if (widthSpec < 0) -1 else widthSpec + + // Height. + var heightSpec: Int + heightSpec = if (this.height == ViewGroup.LayoutParams.MATCH_PARENT) { + if (noInputMethod) height else ViewGroup.LayoutParams.MATCH_PARENT + } else if (this.height == ViewGroup.LayoutParams.WRAP_CONTENT) { + height + } else { + this.height + } + heightSpec = Math.min(heightSpec, mMaxHeight) + heightSpec = if (heightSpec < 0) -1 else heightSpec + + // Update. + mPopup.isOutsideTouchable = isOutsideTouchable + if (heightSpec == 0) { + dismiss() + } else { + mPopup.update(anchorView, mHorizontalOffset, mVerticalOffset, widthSpec, heightSpec) + } + } else { + var widthSpec: Int + widthSpec = if (width == ViewGroup.LayoutParams.MATCH_PARENT) { + ViewGroup.LayoutParams.MATCH_PARENT + } else if (width == ViewGroup.LayoutParams.WRAP_CONTENT) { + anchorView!!.width + } else { + width + } + widthSpec = Math.min(widthSpec, mMaxWidth) + var heightSpec: Int + heightSpec = if (this.height == ViewGroup.LayoutParams.MATCH_PARENT) { + ViewGroup.LayoutParams.MATCH_PARENT + } else if (this.height == ViewGroup.LayoutParams.WRAP_CONTENT) { + height + } else { + this.height + } + heightSpec = Math.min(heightSpec, mMaxHeight) + + // Set width and height. + mPopup.width = widthSpec + mPopup.height = heightSpec + mPopup.isClippingEnabled = true + + // use outside touchable to dismiss drop down when touching outside of it, so + // only set this if the dropdown is not always visible + mPopup.isOutsideTouchable = isOutsideTouchable + PopupWindowCompat.showAsDropDown( + mPopup, + anchorView!!, mHorizontalOffset, mVerticalOffset, mGravity + ) + } + } + + /** + * Dismiss the popup window. + */ + fun dismiss() { + mPopup.dismiss() + mPopup.contentView = null + mView = null + } + + /** + * Control how the popup operates with an input method: one of + * INPUT_METHOD_FROM_FOCUSABLE, INPUT_METHOD_NEEDED, + * or INPUT_METHOD_NOT_NEEDED. + * + * + * If the popup is showing, calling this method will take effect only + * the next time the popup is shown or through a manual call to the [.show] + * method. + * + * @see .show + */ + fun setInputMethodMode(mode: Int) { + mPopup.inputMethodMode = mode + } + + val isShowing: Boolean + /** + * @return `true` if the popup is currently showing, `false` otherwise. + */ + get() = mPopup.isShowing + val isInputMethodNotNeeded: Boolean + /** + * @return `true` if this popup is configured to assume the user does not need + * to interact with the IME while it is showing, `false` otherwise. + */ + get() = mPopup.inputMethodMode == PopupWindow.INPUT_METHOD_NOT_NEEDED + + fun setView(view: ViewGroup?) { + mView = view + mView!!.isFocusable = true + mView!!.isFocusableInTouchMode = true + val dropDownView = mView + mPopup.contentView = dropDownView + val params = mView!!.layoutParams + if (params != null) { + if (params.height > 0) height = params.height + if (params.width > 0) width = params.width + } + } + + /** + * + * Builds the popup window's content and returns the height the popup + * should have. Returns -1 when the content already exists. + * + * @return the content's wrap content height or -1 if content already exists + */ + private fun buildDropDown(): Int { + var otherHeights = 0 + + // getMaxAvailableHeight() subtracts the padding, so we put it back + // to get the available height for the whole window. + val paddingVert: Int + val paddingHoriz: Int + val background = mPopup.background + if (background != null) { + background.getPadding(mTempRect) + paddingVert = mTempRect.top + mTempRect.bottom + paddingHoriz = mTempRect.left + mTempRect.right + + // If we don't have an explicit vertical offset, determine one from + // the window background so that content will line up. + if (!mVerticalOffsetSet) { + mVerticalOffset = -mTempRect.top + } + } else { + mTempRect.setEmpty() + paddingVert = 0 + paddingHoriz = 0 + } + + // Redefine dimensions taking into account maxWidth and maxHeight. + val ignoreBottomDecorations = mPopup.inputMethodMode == PopupWindow.INPUT_METHOD_NOT_NEEDED + val maxContentHeight = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) mPopup.getMaxAvailableHeight( + anchorView!!, mVerticalOffset, ignoreBottomDecorations + ) else mPopup.getMaxAvailableHeight( + anchorView!!, mVerticalOffset + ) + val maxContentWidth = mContext.resources.displayMetrics.widthPixels - paddingHoriz + mMaxHeight = min(maxContentHeight + paddingVert, mUserMaxHeight) + mMaxWidth = min(maxContentWidth + paddingHoriz, mUserMaxWidth) + // if (mHeight > 0) mHeight = Math.min(mHeight, maxContentHeight); + // if (mWidth > 0) mWidth = Math.min(mWidth, maxContentWidth); + if (isDropDownAlwaysVisible || height == ViewGroup.LayoutParams.MATCH_PARENT) { + return mMaxHeight + } + val childWidthSpec: Int = when (width) { + ViewGroup.LayoutParams.WRAP_CONTENT -> View.MeasureSpec.makeMeasureSpec( + maxContentWidth, + View.MeasureSpec.AT_MOST + ) + + ViewGroup.LayoutParams.MATCH_PARENT -> View.MeasureSpec.makeMeasureSpec( + maxContentWidth, + View.MeasureSpec.EXACTLY + ) + + else -> View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY) + } + + // Add padding only if the list has items in it, that way we don't show + // the popup if it is not needed. For this reason, we measure as wrap_content. + mView!!.measure( + childWidthSpec, + View.MeasureSpec.makeMeasureSpec(maxContentHeight, View.MeasureSpec.AT_MOST) + ) + val viewHeight = mView!!.measuredHeight + if (viewHeight > 0) { + otherHeights += paddingVert + mView!!.paddingTop + mView!!.paddingBottom + } + return Math.min(viewHeight + otherHeights, mMaxHeight) + } +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePresenter.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePresenter.kt new file mode 100644 index 0000000000000000000000000000000000000000..c9108733c795d6604a083e8dbdcf67c78f114aa5 --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePresenter.kt @@ -0,0 +1,97 @@ +package org.futo.circles.core.feature.autocomplete + +import android.content.Context +import android.database.DataSetObserver +import android.view.ViewGroup + +/** + * Base class for presenting items inside a popup. This is abstract and must be implemented. + * + * Most important methods are [.getView] and [.onQuery]. + */ +abstract class AutocompletePresenter<T>( + /** + * @return this presenter context + */ + protected val context: Context +) { + + /** + * @return whether we are showing currently + */ + @get:Suppress("unused") + private var isShowing = false + + /** + * At this point the presenter is passed the [ClickProvider]. + * The contract is that [ClickProvider.click] must be called when a list item + * is clicked. This ensure that the autocomplete callback will receive the event. + * + * @param provider a click provider for this presenter. + */ + open fun registerClickProvider(provider: ClickProvider<T>?) {} + + /** + * Useful if you wish to change width/height based on content height. + * The contract is to call [DataSetObserver.onChanged] when your view has + * changes. + * + * This is called after [.getView]. + * + * @param observer the observer. + */ + open fun registerDataSetObserver(observer: DataSetObserver) {} + abstract val view: ViewGroup + val popupDimensions: PopupDimensions + /** + * Provide the [PopupDimensions] for this popup. Called just once. + * You can use fixed dimensions or [android.view.ViewGroup.LayoutParams.WRAP_CONTENT] and + * [android.view.ViewGroup.LayoutParams.MATCH_PARENT]. + * + * @return a PopupDimensions object + */ + get() = PopupDimensions() + + /** + * Perform firther initialization here. Called after [.getView], + * each time the popup is shown. + */ + protected abstract fun onViewShown() + + /** + * Called to update the view to filter results with the query. + * It is called any time the popup is shown, and any time the text changes and query is updated. + * + * @param query query from the edit text, to filter our results + */ + abstract fun onQuery(query: CharSequence?) + + /** + * Called when the popup is hidden, to release resources. + */ + protected abstract fun onViewHidden() + fun showView() { + isShowing = true + onViewShown() + } + + fun hideView() { + isShowing = false + onViewHidden() + } + + interface ClickProvider<T> { + fun click(item: T) + } + + /** + * Provides width, height, maxWidth and maxHeight for the popup. + * @see .getPopupDimensions + */ + class PopupDimensions { + var width = ViewGroup.LayoutParams.WRAP_CONTENT + var height = ViewGroup.LayoutParams.WRAP_CONTENT + var maxWidth = Int.MAX_VALUE + var maxHeight = Int.MAX_VALUE + } +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/CharPolicy.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/CharPolicy.kt new file mode 100644 index 0000000000000000000000000000000000000000..dfa91f349edf125293ffd9b2251ca003ae33e000 --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/CharPolicy.kt @@ -0,0 +1,171 @@ +package org.futo.circles.core.feature.autocomplete + +import android.text.Spannable +import android.text.Spanned +import android.util.Log + +/** + * A special [AutocompletePolicy] for cases when you want to trigger the popup when a + * certain character is shown. + * + * For instance, this might be the case for hashtags ('#') or usernames ('@') or whatever you wish. + * Passing this to [Autocomplete.Builder] ensures the following behavior (assuming '@'): + * - text "@john" : presenter will be passed the query "john" + * - text "You should see this @j" : presenter will be passed the query "j" + * - text "You should see this @john @m" : presenter will be passed the query "m" + */ +class CharPolicy : AutocompletePolicy { + private val CH: Char + private val INT = IntArray(2) + private var needSpaceBefore = true + + /** + * Constructs a char policy for the given character. + * + * @param trigger the triggering character. + */ + constructor(trigger: Char) { + CH = trigger + } + + /** + * Constructs a char policy for the given character. + * You can choose whether a whitespace is needed before 'trigger'. + * + * @param trigger the triggering character. + * @param needSpaceBefore whether we need a space before trigger + */ + @Suppress("unused") + constructor(trigger: Char, needSpaceBefore: Boolean) { + CH = trigger + this.needSpaceBefore = needSpaceBefore + } + + /** + * Can be overriden to understand which characters are valid. The default implementation + * returns true for any character except whitespaces. + * + * @param ch the character + * @return whether it's valid part of a query + */ + protected fun isValidChar(ch: Char): Boolean { + return !Character.isWhitespace(ch) + } + + private fun checkText(text: Spannable, cursorPosParam: Int): IntArray? { + var cursorPos = cursorPosParam + val spanEnd = cursorPos + var last = 'x' + cursorPos -= 1 // If the cursor is at the end, we will have cursorPos = length. Go back by 1. + while (cursorPos >= 0 && last != CH) { + val ch = text[cursorPos] + log("checkText: char is $ch") + if (isValidChar(ch)) { + // We are going back + log("checkText: char is valid") + cursorPos -= 1 + last = ch + } else { + // We got a whitespace before getting a CH. This is invalid. + log("checkText: char is not valid, returning NULL") + return null + } + } + cursorPos += 1 // + 1 because we end BEHIND the valid selection + + // Start checking. + if (cursorPos == 0 && last != CH) { + // We got to the start of the string, and no CH was encountered. Nothing to do. + log("checkText: got to start but no CH, returning NULL") + return null + } + + // Additional checks for cursorPos - 1 + if (cursorPos > 0 && needSpaceBefore) { + val ch = text[cursorPos - 1] + if (!Character.isWhitespace(ch)) { + log("checkText: char before is not whitespace, returning NULL") + return null + } + } + + // All seems OK. + val spanStart = cursorPos + 1 // + 1 because we want to exclude CH from the query + INT[0] = spanStart + INT[1] = spanEnd + log("checkText: found! cursorPos=$cursorPos") + log("checkText: found! spanStart=$spanStart") + log("checkText: found! spanEnd=$spanEnd") + return INT + } + + override fun shouldShowPopup(text: Spannable, cursorPos: Int): Boolean { + // Returning true if, right before cursorPos, we have a word starting with @. + log("shouldShowPopup: text is $text") + log("shouldShowPopup: cursorPos is $cursorPos") + val show = checkText(text, cursorPos) + if (show != null) { + text.setSpan(QuerySpan(), show[0], show[1], Spanned.SPAN_INCLUSIVE_INCLUSIVE) + return true + } + log("shouldShowPopup: returning false") + return false + } + + override fun shouldDismissPopup(text: Spannable, cursorPos: Int): Boolean { + log("shouldDismissPopup: text is $text") + log("shouldDismissPopup: cursorPos is $cursorPos") + val dismiss = checkText(text, cursorPos) == null + log("shouldDismissPopup: returning $dismiss") + return dismiss + } + + override fun getQuery(text: Spannable): CharSequence { + val span = text.getSpans(0, text.length, QuerySpan::class.java) + if (span == null || span.size == 0) { + // Should never happen. + log("getQuery: there's no span!") + return "" + } + log("getQuery: found spans: " + span.size) + val sp = span[0] + log("getQuery: span start is " + text.getSpanStart(sp)) + log("getQuery: span end is " + text.getSpanEnd(sp)) + val seq = text.subSequence(text.getSpanStart(sp), text.getSpanEnd(sp)) + log("getQuery: returning $seq") + return seq + } + + override fun onDismiss(text: Spannable) { + // Remove any span added by shouldShow. Should be useless, but anyway. + val span = text.getSpans(0, text.length, QuerySpan::class.java) + for (s in span) { + text.removeSpan(s) + } + } + + private class QuerySpan + companion object { + private val TAG = CharPolicy::class.java.simpleName + private const val DEBUG = false + private fun log(log: String) { + if (DEBUG) Log.e(TAG, log) + } + + /** + * Returns the current query out of the given Spannable. + * @param text the anchor text + * @return an int[] with query start and query end positions + */ + fun getQueryRange(text: Spannable): IntArray? { + val span = text.getSpans(0, text.length, QuerySpan::class.java) + if (span == null || span.size == 0) return null + if (span.size > 1) { + // Won't happen + log("getQueryRange: ERR: MORE THAN ONE QuerySpan.") + } + val sp = span[0] + return intArrayOf(text.getSpanStart(sp), text.getSpanEnd(sp)) + } + } +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/RecyclerViewPresenter.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/RecyclerViewPresenter.kt new file mode 100644 index 0000000000000000000000000000000000000000..a805a8921bf40fc4b900e97e16a6a1f250e61854 --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/RecyclerViewPresenter.kt @@ -0,0 +1,121 @@ +package org.futo.circles.core.feature.autocomplete + +import android.content.Context +import android.database.DataSetObserver +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver + +/** + * Simple [AutocompletePresenter] implementation that hosts a [RecyclerView]. + * Supports [android.view.ViewGroup.LayoutParams.WRAP_CONTENT] natively. + * The only contract is to + * + * - provide a [RecyclerView.Adapter] in [.instantiateAdapter] + * - call [.dispatchClick] when an object is clicked + * - update your data during [.onQuery] + * + * @param <T> your model object (the object displayed by the list) +</T> */ +abstract class RecyclerViewPresenter<T>(context: Context) : AutocompletePresenter<T>(context) { + @get:Suppress("unused") + private var recyclerView: RecyclerView? = null + private set + private var clicks: ClickProvider<T>? = null + private var observer: Observer? = null + override fun registerClickProvider(provider: ClickProvider<T>?) { + clicks = provider + } + + override fun registerDataSetObserver(observer: DataSetObserver) { + this.observer = Observer(observer) + } + + override val view: ViewGroup + get() { + recyclerView = RecyclerView(context) + val adapter = instantiateAdapter() + recyclerView!!.adapter = adapter + recyclerView!!.layoutManager = instantiateLayoutManager() + if (observer != null) { + adapter.registerAdapterDataObserver(observer!!) + observer = null + } + return recyclerView!! + } + + override fun onViewShown() {} + + @CallSuper + override fun onViewHidden() { + recyclerView = null + observer = null + } + + /** + * Dispatch click event to [AutocompleteCallback]. + * Should be called when items are clicked. + * + * @param item the clicked item. + */ + protected fun dispatchClick(item: T) { + if (clicks != null) clicks!!.click(item) + } + + /** + * Request that the popup should recompute its dimensions based on a recent change in + * the view being displayed. + * + * This is already managed internally for [RecyclerView] events. + * Only use it for changes in other views that you have added to the popup, + * and only if one of the dimensions for the popup is WRAP_CONTENT . + */ + @Suppress("unused") + protected fun dispatchLayoutChange() { + observer?.onChanged() + } + + /** + * Provide an adapter for the recycler. + * This should be a fresh instance every time this is called. + * + * @return a new adapter. + */ + protected abstract fun instantiateAdapter(): RecyclerView.Adapter<*> + + /** + * Provides a layout manager for the recycler. + * This should be a fresh instance every time this is called. + * Defaults to a vertical LinearLayoutManager, which is guaranteed to work well. + * + * @return a new layout manager. + */ + protected fun instantiateLayoutManager(): RecyclerView.LayoutManager { + return LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + } + + private class Observer(private val root: DataSetObserver) : + AdapterDataObserver() { + override fun onChanged() { + root.onChanged() + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { + root.onChanged() + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { + root.onChanged() + } + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + root.onChanged() + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + root.onChanged() + } + } +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/feature/textDrawable/ColorGenerator.kt b/core/src/main/java/org/futo/circles/core/feature/textDrawable/ColorGenerator.kt new file mode 100644 index 0000000000000000000000000000000000000000..a14beb4cd127ae23b88bb0809bb1e5c2ecf7308c --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/textDrawable/ColorGenerator.kt @@ -0,0 +1,22 @@ +package org.futo.circles.core.feature.textDrawable + +import kotlin.math.abs + +class ColorGenerator { + + private val colors = mutableListOf( + -0xe9c9c, + -0xa7aa7, + -0x65bc2, + -0x1b39d2, + -0x98408c, + -0xa65d42, + -0xdf6c33, + -0x529d59, + -0x7fa87f + ) + + fun getColor(key: Any): Int { + return colors[abs(key.hashCode()) % colors.size] + } +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/feature/textDrawable/TextDrawable.kt b/core/src/main/java/org/futo/circles/core/feature/textDrawable/TextDrawable.kt new file mode 100644 index 0000000000000000000000000000000000000000..721ed1f2d626c4bdf76aa7234c8a63450e0bec4e --- /dev/null +++ b/core/src/main/java/org/futo/circles/core/feature/textDrawable/TextDrawable.kt @@ -0,0 +1,297 @@ +package org.futo.circles.core.feature.textDrawable + + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.graphics.drawable.shapes.RectShape +import android.graphics.drawable.shapes.RoundRectShape +import android.graphics.drawable.shapes.Shape +import androidx.annotation.ColorInt +import androidx.annotation.IntDef +import java.util.Locale +import kotlin.math.min + + +class TextDrawable private constructor(builder: Builder) : ShapeDrawable(builder.getShape()) { + private var bitmap: Bitmap? = null + private val borderColor: Int + private val borderPaint: Paint + private val borderThickness: Int + private val fontSize: Int + private val height: Int + private val text: String + private val textPaint: Paint + private val radius: Float + + @TextDrawableShape + private val shape: Int + private val width: Int + + init { + + // shape properties + shape = builder.shape + height = builder.height + width = builder.width + radius = builder.radius + + // text and color + text = + if (builder.toUpperCase) builder.text.uppercase(Locale.getDefault()) else builder.text + + // text paint settings + fontSize = builder.fontSize + textPaint = Paint() + textPaint.isAntiAlias = true + textPaint.color = builder.textColor + textPaint.isFakeBoldText = builder.isBold + textPaint.strokeWidth = builder.borderThickness.toFloat() + textPaint.style = Paint.Style.FILL + textPaint.textAlign = Paint.Align.CENTER + + // border paint settings + borderThickness = builder.borderThickness + borderColor = builder.borderColor + borderPaint = Paint() + if (borderColor == -1) borderPaint.color = + getDarkerShade(builder.color) else borderPaint.color = + borderColor + borderPaint.style = Paint.Style.STROKE + borderPaint.strokeWidth = borderThickness.toFloat() + + // drawable paint setColor + val paint = paint + paint.color = builder.color + + //custom centre drawable + builder.drawable?.let { + if (builder.drawable is BitmapDrawable) { + bitmap = (builder.drawable as BitmapDrawable).bitmap + } else { + bitmap = Bitmap.createBitmap( + it.intrinsicWidth, + it.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap!!) + it.setBounds(0, 0, it.intrinsicWidth, it.intrinsicHeight) + it.draw(canvas) + } + } + } + + private fun getDarkerShade(@ColorInt color: Int): Int { + return Color.rgb( + (SHADE_FACTOR * Color.red(color)).toInt(), + (SHADE_FACTOR * Color.green(color)).toInt(), + (SHADE_FACTOR * Color.blue(color)).toInt() + ) + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + val r = bounds + // draw border + if (borderThickness > 0) { + drawBorder(canvas) + } + val count = canvas.save() + if (bitmap == null) { + canvas.translate(r.left.toFloat(), r.top.toFloat()) + } + // draw text + val width = if (width < 0) r.width() else width + val height = if (height < 0) r.height() else height + val fontSize = if (fontSize < 0) min(width, height) / 2 else fontSize + textPaint.textSize = fontSize.toFloat() + val textBounds = Rect() + textPaint.getTextBounds(text, 0, text.length, textBounds) + canvas.drawText( + text, + (width / 2).toFloat(), + height / 2 - textBounds.exactCenterY(), + textPaint + ) + if (bitmap == null) { + textPaint.textSize = fontSize.toFloat() + canvas.drawText( + text, (width / 2).toFloat(), + height / 2 - (textPaint.descent() + textPaint.ascent()) / 2, textPaint + ) + } else { + canvas.drawBitmap( + bitmap!!, + ((width - bitmap!!.width) / 2).toFloat(), + ((height - bitmap!!.height) / 2).toFloat(), + null + ) + } + canvas.restoreToCount(count) + } + + private fun drawBorder(canvas: Canvas) { + val rect = RectF(bounds) + rect.inset((borderThickness / 2).toFloat(), (borderThickness / 2).toFloat()) + when (shape) { + SHAPE_ROUND_RECT -> canvas.drawRoundRect(rect, radius, radius, borderPaint) + SHAPE_ROUND -> canvas.drawOval(rect, borderPaint) + SHAPE_RECT -> canvas.drawRect(rect, borderPaint) + else -> canvas.drawRect(rect, borderPaint) + } + } + + override fun setAlpha(alpha: Int) { + textPaint.alpha = alpha + } + + override fun setColorFilter(cf: ColorFilter?) { + textPaint.colorFilter = cf + } + + override fun getIntrinsicWidth(): Int = width + + override fun getIntrinsicHeight(): Int = height + + fun getBitmap(): Bitmap { + val bitmap: Bitmap = if (intrinsicWidth <= 0 || intrinsicHeight <= 0) { + Bitmap.createBitmap( + 1, + 1, + if (opacity != PixelFormat.OPAQUE) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 + ) + } else { + Bitmap.createBitmap( + intrinsicWidth, intrinsicHeight, + if (opacity != PixelFormat.OPAQUE) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 + ) + } + val canvas = Canvas(bitmap) + setBounds(0, 0, canvas.width, canvas.height) + draw(canvas) + return bitmap + } + + class Builder { + internal var borderColor: Int + internal var borderThickness = 0 + internal var color: Int + var drawable: Drawable? = null + internal var fontSize: Int + internal var height: Int + internal var isBold = false + var radius = 0f + internal var shape: Int + internal var text = "" + var textColor: Int + internal val toUpperCase = false + internal var width: Int + + init { + borderColor = -1 + color = Color.GRAY + fontSize = -1 + height = -1 + shape = SHAPE_RECT + textColor = Color.WHITE + width = -1 + } + + fun setBold(): Builder { + isBold = true + return this + } + + fun setBorder(thickness: Int): Builder { + borderThickness = thickness + return this + } + + fun setBorderColor(@ColorInt color: Int): Builder { + borderColor = color + return this + } + + fun setColor(@ColorInt color: Int): Builder { + this.color = color + return this + } + + fun setDrawable(drawable: Drawable): Builder { + this.drawable = drawable + return this + } + + + fun setFontSize(size: Int): Builder { + fontSize = size + return this + } + + fun setHeight(height: Int): Builder { + this.height = height + return this + } + + fun setRadius(radius: Int): Builder { + this.radius = radius.toFloat() + return this + } + + fun setShape(@TextDrawableShape shape: Int): Builder { + this.shape = shape + return this + } + + fun setText(text: String): Builder { + this.text = text + return this + } + + fun setTextColor(@ColorInt color: Int): Builder { + textColor = color + return this + } + + fun setWidth(width: Int): Builder { + this.width = width + return this + } + + internal fun getShape(): Shape { + return when (shape) { + SHAPE_ROUND_RECT -> { + val radii = + floatArrayOf(radius, radius, radius, radius, radius, radius, radius, radius) + RoundRectShape(radii, null, null) + } + + SHAPE_ROUND -> OvalShape() + SHAPE_RECT -> RectShape() + else -> RectShape() + } + } + + fun build(): TextDrawable { + return TextDrawable(this) + } + } + + @IntDef(*[SHAPE_RECT, SHAPE_ROUND_RECT, SHAPE_ROUND]) + annotation class TextDrawableShape + companion object { + const val SHAPE_RECT = 0 + const val SHAPE_ROUND_RECT = 1 + const val SHAPE_ROUND = 2 + private const val SHADE_FACTOR = 0.9f + } +} \ No newline at end of file diff --git a/core/src/main/java/org/futo/circles/core/glide/GlideShortcutUtils.kt b/core/src/main/java/org/futo/circles/core/glide/GlideShortcutUtils.kt index f6c0f6e0c8d1357f7ac49665201819c9c4a60998..c554b8c3a5acbded60898c99a6500630902cb392 100644 --- a/core/src/main/java/org/futo/circles/core/glide/GlideShortcutUtils.kt +++ b/core/src/main/java/org/futo/circles/core/glide/GlideShortcutUtils.kt @@ -3,13 +3,13 @@ package org.futo.circles.core.glide import android.graphics.Bitmap import android.graphics.Color import androidx.annotation.AnyThread -import com.amulyakhare.textdrawable.TextDrawable -import com.amulyakhare.textdrawable.util.ColorGenerator import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.signature.ObjectKey import org.futo.circles.core.extensions.resolveUrl +import org.futo.circles.core.feature.textDrawable.ColorGenerator +import org.futo.circles.core.feature.textDrawable.TextDrawable import org.futo.circles.core.provider.MatrixSessionProvider import org.matrix.android.sdk.api.util.MatrixItem @@ -63,12 +63,12 @@ object GlideShortcutUtils { it.load( TextDrawable.Builder() .setShape(TextDrawable.SHAPE_RECT) - .setColor(ColorGenerator.DEFAULT.getColor(matrixItem)) + .setColor(ColorGenerator().getColor(matrixItem)) .setTextColor(Color.WHITE) .setWidth(iconSize) .setHeight(iconSize) .setText(matrixItem.firstLetterOfDisplayName()) - .build().bitmap + .build().getBitmap() ) } } diff --git a/core/src/main/java/org/futo/circles/core/view/NotificationCounterView.kt b/core/src/main/java/org/futo/circles/core/view/NotificationCounterView.kt index 83516b7506f1d594b74909108866977882005d72..1953c499b46ca00a4095ec48f7ee869886b9e7bd 100644 --- a/core/src/main/java/org/futo/circles/core/view/NotificationCounterView.kt +++ b/core/src/main/java/org/futo/circles/core/view/NotificationCounterView.kt @@ -6,11 +6,11 @@ import android.util.AttributeSet import android.view.LayoutInflater import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat -import com.amulyakhare.textdrawable.TextDrawable import org.futo.circles.core.R import org.futo.circles.core.databinding.ViewNotificationCounterBinding import org.futo.circles.core.extensions.getAttributes import org.futo.circles.core.extensions.setIsVisible +import org.futo.circles.core.feature.textDrawable.TextDrawable class NotificationCounterView( context: Context, diff --git a/gradle.properties b/gradle.properties index a7cfaffbca0b3c2866b1899564d46fb7840d2698..cccbfe6f22597988532e3fc6116d8379d8e2f888 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the