mirror of
https://github.com/quik-sms/quik.git
synced 2026-11-15 10:38:53 +01:00
feat(reactions): grid emoji picker, configurable quick reactions, open animation
- Fix swipe-to-react: enable stable ids on MessagesAdapter so the swipe callback resolves the swiped message (itemId was NO_ID -> lookup failed), and anchor the reaction popup independent of swipe-recovery translation so it no longer opens off-screen. - Replace the typed-text reaction "+" with a themed, scrollable emoji grid (EmojiPicker) generated from colour-emoji codepoint ranges plus a curated common set. No third-party picker, so no Guava and it follows light/dark. - Make the six quick reactions user-configurable and reorderable via a new Settings -> Quick reactions editor; the double-tap default reaction now uses the same grid picker instead of a raw text dialog. - Pulse visible reaction badges (1 -> 1.4 -> 0.9 -> 1, cascaded 0.25s) when a conversation opens with a reaction as its newest message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,10 @@ class Preferences @Inject constructor(
|
||||
const val MESSAGE_SWIPE_ACTION_DELETE = 5
|
||||
|
||||
const val DEFAULT_REACTION_EMOJI = "👍"
|
||||
|
||||
// Quick-reaction emojis shown in the reaction popup, comma separated (user reorderable)
|
||||
const val DEFAULT_QUICK_REACTIONS = "❤️,👍,👎,😂,😮,😢"
|
||||
const val QUICK_REACTIONS_COUNT = 6
|
||||
}
|
||||
|
||||
// Internal
|
||||
@@ -133,6 +137,8 @@ class Preferences @Inject constructor(
|
||||
val swipeRight = rxPrefs.getInteger("swipeRight", SWIPE_ACTION_ARCHIVE)
|
||||
val swipeLeft = rxPrefs.getInteger("swipeLeft", SWIPE_ACTION_ARCHIVE)
|
||||
val defaultReactionEmoji = rxPrefs.getString("defaultReactionEmoji", DEFAULT_REACTION_EMOJI)
|
||||
|
||||
val quickReactions = rxPrefs.getString("quickReactions", DEFAULT_QUICK_REACTIONS)
|
||||
val messageSwipeRight = rxPrefs.getInteger("messageSwipeRight", MESSAGE_SWIPE_ACTION_REACT)
|
||||
val messageSwipeLeft = rxPrefs.getInteger("messageSwipeLeft", MESSAGE_SWIPE_ACTION_REPLY)
|
||||
val autoEmoji = rxPrefs.getBoolean("autoEmoji", true)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
|
||||
*
|
||||
* This file is part of QKSMS.
|
||||
*
|
||||
* QKSMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QKSMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QKSMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package dev.octoshrimpy.quik.common.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Paint
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import dev.octoshrimpy.quik.R
|
||||
import dev.octoshrimpy.quik.common.util.extensions.dpToPx
|
||||
import dev.octoshrimpy.quik.common.util.extensions.resolveThemeColor
|
||||
|
||||
/**
|
||||
* A lightweight, dependency-free emoji picker. Renders a themed, scrollable grid of emoji in a
|
||||
* bottom sheet so it follows Quik's light/dark/black themes (unlike androidx emoji2-emojipicker,
|
||||
* which pulled in Guava and ignored the app theme — see project memory).
|
||||
*/
|
||||
object EmojiPicker {
|
||||
|
||||
// Popular reactions shown first. These include emoji-presentation sequences (base char +
|
||||
// U+FE0F) that must be spelled out to render in colour — plain codepoint iteration would
|
||||
// show them as black-and-white text glyphs.
|
||||
private val commonEmojis = listOf(
|
||||
"❤️", "👍", "👎", "😂", "😮", "😢", "🙏", "🔥", "🎉", "👏",
|
||||
"💯", "😍", "🥰", "😊", "🤔", "😎", "😭", "😅", "😡", "🤣",
|
||||
"😘", "👀", "✨", "⭐", "✅", "❌", "✌️", "🤝", "🙌", "💪"
|
||||
)
|
||||
|
||||
// Unicode blocks that default to emoji (colour) presentation without a variation selector,
|
||||
// so they enumerate cleanly. Sequences/flags/skin-tones aren't covered by codepoint walking.
|
||||
private val emojiRanges = listOf(
|
||||
0x1F300..0x1F5FF, // Misc Symbols and Pictographs
|
||||
0x1F600..0x1F64F, // Emoticons
|
||||
0x1F680..0x1F6FF, // Transport and Map
|
||||
0x1F900..0x1F9FF, // Supplemental Symbols and Pictographs
|
||||
0x1FA70..0x1FAFF // Symbols and Pictographs Extended-A
|
||||
)
|
||||
|
||||
/** Common emoji first, then every renderable codepoint from the colour-emoji ranges. */
|
||||
val emojis: List<String> by lazy {
|
||||
val paint = Paint()
|
||||
val generated = emojiRanges.asSequence()
|
||||
.flatMap { it.asSequence() }
|
||||
.mapNotNull { cp ->
|
||||
val s = String(Character.toChars(cp))
|
||||
if (paint.hasGlyph(s)) s else null
|
||||
}
|
||||
(commonEmojis.asSequence() + generated).distinct().toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the emoji grid in a bottom sheet. [onPicked] receives the chosen emoji and the sheet
|
||||
* dismisses. [context] must be a themed (activity) context.
|
||||
*/
|
||||
fun show(context: Context, onPicked: (String) -> Unit) {
|
||||
val backgroundColor = context.resolveThemeColor(android.R.attr.windowBackground)
|
||||
val textColor = context.resolveThemeColor(android.R.attr.textColorPrimary)
|
||||
val cell = 48.dpToPx(context)
|
||||
val span = (context.resources.displayMetrics.widthPixels / cell).coerceAtLeast(6)
|
||||
|
||||
val sheet = BottomSheetDialog(context)
|
||||
|
||||
val recycler = RecyclerView(context).apply {
|
||||
setBackgroundColor(backgroundColor)
|
||||
layoutManager = GridLayoutManager(context, span)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
(context.resources.displayMetrics.heightPixels * 0.5f).toInt()
|
||||
)
|
||||
adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
object : RecyclerView.ViewHolder(TextView(context).apply {
|
||||
textSize = 22f
|
||||
gravity = Gravity.CENTER
|
||||
includeFontPadding = false
|
||||
setTextColor(textColor)
|
||||
setBackgroundResource(R.drawable.ripple)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, cell
|
||||
)
|
||||
}) {}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val emoji = emojis[position]
|
||||
(holder.itemView as TextView).text = emoji
|
||||
holder.itemView.setOnClickListener {
|
||||
onPicked(emoji)
|
||||
sheet.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = emojis.size
|
||||
}
|
||||
}
|
||||
|
||||
sheet.setContentView(recycler)
|
||||
sheet.show()
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@ import android.view.MenuItem
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.PopupWindow
|
||||
import android.widget.SeekBar
|
||||
@@ -75,6 +74,7 @@ import dev.octoshrimpy.quik.common.util.DateFormatter
|
||||
import dev.octoshrimpy.quik.common.util.extensions.autoScrollToStart
|
||||
import dev.octoshrimpy.quik.common.util.extensions.dpToPx
|
||||
import dev.octoshrimpy.quik.common.util.extensions.hideKeyboard
|
||||
import dev.octoshrimpy.quik.common.util.EmojiPicker
|
||||
import dev.octoshrimpy.quik.common.util.extensions.makeToast
|
||||
import dev.octoshrimpy.quik.common.util.extensions.scrapViews
|
||||
import dev.octoshrimpy.quik.common.util.extensions.setBackgroundTint
|
||||
@@ -87,6 +87,7 @@ import dev.octoshrimpy.quik.feature.compose.editing.ChipsAdapter
|
||||
import dev.octoshrimpy.quik.feature.contacts.ContactsActivity
|
||||
import dev.octoshrimpy.quik.model.Attachment
|
||||
import dev.octoshrimpy.quik.model.Recipient
|
||||
import dev.octoshrimpy.quik.util.Preferences
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.Disposable
|
||||
@@ -732,7 +733,12 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
|
||||
}
|
||||
|
||||
override fun showReactionPicker(messageId: Long) {
|
||||
val defaultEmojis = listOf("❤️", "👍", "👎", "😂", "😮", "😢")
|
||||
// User-configurable quick reactions (Settings → Quick reactions); fall back to defaults
|
||||
val defaultEmojis = prefs.quickReactions.get()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.ifEmpty { Preferences.DEFAULT_QUICK_REACTIONS.split(",") }
|
||||
val theme = colors.theme()
|
||||
val vPad = 8.dpToPx(this)
|
||||
|
||||
@@ -774,18 +780,9 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
|
||||
row.addView(emojiView(emoji) { sendReactionIntent.onNext(Pair(messageId, emoji)) })
|
||||
}
|
||||
|
||||
// "+" opens a simple input so the user can type or paste any emoji
|
||||
// "+" opens the full scrollable emoji grid so any emoji can be chosen
|
||||
row.addView(emojiView("+") {
|
||||
val input = EditText(this).apply { hint = getString(R.string.compose_reaction_custom_hint) }
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.compose_reaction_custom_title)
|
||||
.setView(input)
|
||||
.setPositiveButton(R.string.button_send) { _, _ ->
|
||||
val emoji = input.text.toString().trim()
|
||||
if (emoji.isNotEmpty()) sendReactionIntent.onNext(Pair(messageId, emoji))
|
||||
}
|
||||
.setNegativeButton(R.string.button_cancel, null)
|
||||
.show()
|
||||
EmojiPicker.show(this) { emoji -> sendReactionIntent.onNext(Pair(messageId, emoji)) }
|
||||
})
|
||||
|
||||
// Anchor above the long-pressed message if its view is on screen; else center on the list
|
||||
@@ -794,17 +791,28 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
|
||||
.takeIf { it != -1 }
|
||||
?.let { binding.messageList.layoutManager?.findViewByPosition(it) }
|
||||
|
||||
row.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(popupWidth, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.UNSPECIFIED
|
||||
)
|
||||
|
||||
if (messageView != null) {
|
||||
row.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(popupWidth, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.UNSPECIFIED
|
||||
)
|
||||
popup.showAsDropDown(messageView, margin, -(messageView.height + row.measuredHeight))
|
||||
val loc = IntArray(2)
|
||||
messageView.getLocationInWindow(loc)
|
||||
// Neutralize any in-progress swipe-recovery translation so the popup is not
|
||||
// placed off-screen. The popup is ~full width, so anchor x at a fixed margin
|
||||
// rather than to the (possibly still translated) swiped row.
|
||||
val top = loc[1] - messageView.translationY.toInt()
|
||||
val y = (top - row.measuredHeight).let { above ->
|
||||
if (above >= 0) above else top + messageView.height // flip below if no room above
|
||||
}
|
||||
popup.showAtLocation(binding.messageList, Gravity.NO_GRAVITY, margin, y)
|
||||
} else {
|
||||
popup.showAtLocation(binding.messageList, Gravity.CENTER, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.compose, menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
|
||||
@@ -23,6 +23,8 @@ import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.Layout
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
@@ -35,6 +37,8 @@ import android.text.util.Linkify
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
@@ -103,6 +107,9 @@ class MessagesAdapter @Inject constructor(
|
||||
private const val MAX_MESSAGE_DISPLAY_LENGTH = 5000
|
||||
|
||||
private const val DOUBLE_TAP_TIMEOUT_MS = 300L
|
||||
|
||||
// delay between each reaction badge's pulse when a conversation opens on a reaction
|
||||
private const val REACTION_PULSE_STAGGER_MS = 250L
|
||||
}
|
||||
|
||||
// tracks the previous tap so we can detect a double-tap without an OnTouchListener
|
||||
@@ -110,6 +117,13 @@ class MessagesAdapter @Inject constructor(
|
||||
private var lastTapMessageId = -1L
|
||||
private var lastTapTime = 0L
|
||||
|
||||
// When the conversation opens with a reaction as the newest message, the visible reaction
|
||||
// badges pulse once, cascaded by REACTION_PULSE_STAGGER_MS. pulsedReactionIds keeps each
|
||||
// badge to a single pulse despite RecyclerView rebinding.
|
||||
private var pendingReactionPulse = false
|
||||
private var reactionPulseOrder = 0
|
||||
private val pulsedReactionIds = mutableSetOf<Long>()
|
||||
|
||||
// click events passed back to compose view model
|
||||
val partClicks: Subject<Long> = PublishSubject.create()
|
||||
val messageLinkClicks: Subject<Uri> = PublishSubject.create()
|
||||
@@ -128,6 +142,15 @@ class MessagesAdapter @Inject constructor(
|
||||
field = value
|
||||
contactCache.clear()
|
||||
|
||||
// Arm the reaction pulse if the newest message in the thread is a reaction. Binds for
|
||||
// the initially-visible badges run synchronously on the next layout pass, so they all
|
||||
// see the flag; the delayed reset only stops badges scrolled into view later.
|
||||
if (value?.first?.lastMessage?.isEmojiReaction == true && !pendingReactionPulse) {
|
||||
pendingReactionPulse = true
|
||||
reactionPulseOrder = 0
|
||||
Handler(Looper.getMainLooper()).postDelayed({ pendingReactionPulse = false }, 1500)
|
||||
}
|
||||
|
||||
updateData(value?.second)
|
||||
}
|
||||
|
||||
@@ -145,6 +168,13 @@ class MessagesAdapter @Inject constructor(
|
||||
|
||||
private val audioState = AudioState()
|
||||
|
||||
init {
|
||||
// Stable ids so RecyclerView populates viewHolder.itemId from getItemId(); the swipe
|
||||
// callback (MessageItemTouchCallback) reads itemId to identify the swiped message.
|
||||
// Without this, itemId is NO_ID (-1) and swipe-to-react can't resolve the message.
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder {
|
||||
// Use the parent's context to inflate the layout, otherwise link clicks will crash the app
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
@@ -469,12 +499,48 @@ class MessagesAdapter @Inject constructor(
|
||||
reactionTextView.text = reactionText
|
||||
reactionTextView.setOnClickListener { reactionClicks.onNext(message.id) }
|
||||
reactionsContainer.setVisible(true)
|
||||
|
||||
// clear any leftover scale from a recycled view
|
||||
reactionTextView.animate().cancel()
|
||||
reactionTextView.scaleX = 1f
|
||||
reactionTextView.scaleY = 1f
|
||||
|
||||
// pulse once, cascaded, when the conversation opened on a reaction
|
||||
if (pendingReactionPulse && pulsedReactionIds.add(message.id)) {
|
||||
bounceReaction(reactionTextView, reactionPulseOrder++ * REACTION_PULSE_STAGGER_MS)
|
||||
}
|
||||
} else {
|
||||
reactionsContainer.setVisible(false)
|
||||
reactionTextView.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Grow-and-shrink bounce: 1 → 1.4 → 0.9 → 1, after [startDelay] ms. */
|
||||
private fun bounceReaction(view: View, startDelay: Long) {
|
||||
view.scaleX = 1f
|
||||
view.scaleY = 1f
|
||||
view.animate()
|
||||
.scaleX(1.4f).scaleY(1.4f)
|
||||
.setStartDelay(startDelay)
|
||||
.setDuration(150)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.withEndAction {
|
||||
view.animate()
|
||||
.scaleX(0.9f).scaleY(0.9f)
|
||||
.setStartDelay(0)
|
||||
.setDuration(120)
|
||||
.withEndAction {
|
||||
view.animate()
|
||||
.scaleX(1f).scaleY(1f)
|
||||
.setDuration(120)
|
||||
.setInterpolator(OvershootInterpolator())
|
||||
.start()
|
||||
}
|
||||
.start()
|
||||
}
|
||||
.start()
|
||||
}
|
||||
|
||||
|
||||
private fun bindStatus(
|
||||
statusView: TextView,
|
||||
|
||||
@@ -24,9 +24,15 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.text.format.DateFormat
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.jakewharton.rxbinding2.view.clicks
|
||||
@@ -40,7 +46,10 @@ import dev.octoshrimpy.quik.common.QkChangeHandler
|
||||
import dev.octoshrimpy.quik.common.QkDialog
|
||||
import dev.octoshrimpy.quik.common.base.QkController
|
||||
import dev.octoshrimpy.quik.common.util.Colors
|
||||
import dev.octoshrimpy.quik.common.util.EmojiPicker
|
||||
import dev.octoshrimpy.quik.common.util.extensions.animateLayoutChanges
|
||||
import dev.octoshrimpy.quik.common.util.extensions.dpToPx
|
||||
import dev.octoshrimpy.quik.common.util.extensions.resolveThemeColor
|
||||
import dev.octoshrimpy.quik.common.util.extensions.setBackgroundTint
|
||||
import dev.octoshrimpy.quik.common.util.extensions.setVisible
|
||||
import dev.octoshrimpy.quik.common.widget.PreferenceView
|
||||
@@ -56,6 +65,7 @@ import dev.octoshrimpy.quik.util.Preferences
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.subjects.PublishSubject
|
||||
import io.reactivex.subjects.Subject
|
||||
import java.util.Collections
|
||||
import javax.inject.Inject
|
||||
|
||||
class SettingsController : QkController<SettingsControllerBinding, SettingsView, SettingsState, SettingsPresenter>(), SettingsView {
|
||||
@@ -77,16 +87,12 @@ class SettingsController : QkController<SettingsControllerBinding, SettingsView,
|
||||
TextInputDialog(activity!!, context.getString(R.string.settings_signature_title), signatureSubject::onNext)
|
||||
}
|
||||
|
||||
private val defaultReactionDialog: TextInputDialog by lazy {
|
||||
TextInputDialog(activity!!, context.getString(R.string.settings_default_reaction_title),
|
||||
defaultReactionSubject::onNext)
|
||||
}
|
||||
|
||||
private val viewQksmsPlusSubject: Subject<Unit> = PublishSubject.create()
|
||||
private val startTimeSelectedSubject: Subject<Pair<Int, Int>> = PublishSubject.create()
|
||||
private val endTimeSelectedSubject: Subject<Pair<Int, Int>> = PublishSubject.create()
|
||||
private val signatureSubject: Subject<String> = PublishSubject.create()
|
||||
private val defaultReactionSubject: Subject<String> = PublishSubject.create()
|
||||
private val quickReactionsSubject: Subject<String> = PublishSubject.create()
|
||||
|
||||
private val progressAnimator by lazy { ObjectAnimator.ofInt(binding.syncingProgress, "progress", 0, 0) }
|
||||
|
||||
@@ -147,6 +153,8 @@ class SettingsController : QkController<SettingsControllerBinding, SettingsView,
|
||||
|
||||
override fun defaultReactionChanged(): Observable<String> = defaultReactionSubject.hide()
|
||||
|
||||
override fun quickReactionsChanged(): Observable<String> = quickReactionsSubject.hide()
|
||||
|
||||
override fun mmsSizeSelected(): Observable<Int> = mmsSizeDialog.adapter.menuItemClicks
|
||||
|
||||
override fun messageLinkHandlingSelected(): Observable<Int> = messageLinkHandlingDialog.adapter.menuItemClicks
|
||||
@@ -177,6 +185,8 @@ class SettingsController : QkController<SettingsControllerBinding, SettingsView,
|
||||
|
||||
binding.defaultReaction.summary = state.defaultReactionEmoji
|
||||
|
||||
binding.quickReactions.summary = state.quickReactions.replace(",", " ")
|
||||
|
||||
binding.textSize.summary = state.textSizeSummary
|
||||
textSizeDialog.adapter.selectedItem = state.textSizeId
|
||||
|
||||
@@ -249,7 +259,87 @@ class SettingsController : QkController<SettingsControllerBinding, SettingsView,
|
||||
|
||||
override fun showSignatureDialog(signature: String) = signatureDialog.setText(signature).show()
|
||||
|
||||
override fun showDefaultReactionDialog(current: String) = defaultReactionDialog.setText(current).show()
|
||||
override fun showDefaultReactionDialog(current: String) {
|
||||
EmojiPicker.show(activity!!) { emoji -> defaultReactionSubject.onNext(emoji) }
|
||||
}
|
||||
|
||||
override fun showQuickReactionsDialog(current: List<String>) {
|
||||
val ctx = activity!!
|
||||
val count = Preferences.QUICK_REACTIONS_COUNT
|
||||
val items = current.filter { it.isNotEmpty() }.toMutableList()
|
||||
while (items.size < count) items.add("➕")
|
||||
while (items.size > count) items.removeAt(items.lastIndex)
|
||||
|
||||
val textColor = ctx.resolveThemeColor(android.R.attr.textColorPrimary)
|
||||
val cell = 48.dpToPx(ctx)
|
||||
|
||||
lateinit var slotAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>
|
||||
slotAdapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
object : RecyclerView.ViewHolder(TextView(ctx).apply {
|
||||
textSize = 24f
|
||||
gravity = Gravity.CENTER
|
||||
includeFontPadding = false
|
||||
setTextColor(textColor)
|
||||
setBackgroundResource(R.drawable.ripple)
|
||||
// each slot fills its grid column so all of them fit the dialog width
|
||||
// (evenly spaced) without horizontal scrolling
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, cell
|
||||
)
|
||||
}) {}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
(holder.itemView as TextView).text = items[position]
|
||||
// tap a slot to replace its emoji via the full picker
|
||||
holder.itemView.setOnClickListener {
|
||||
EmojiPicker.show(ctx) { emoji ->
|
||||
items[holder.adapterPosition] = emoji
|
||||
slotAdapter.notifyItemChanged(holder.adapterPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
|
||||
val recycler = RecyclerView(ctx).apply {
|
||||
// one row of equal-width columns -> all slots fit, evenly spaced, no scrolling
|
||||
layoutManager = GridLayoutManager(ctx, count)
|
||||
adapter = slotAdapter
|
||||
setPadding(8.dpToPx(ctx), 16.dpToPx(ctx), 8.dpToPx(ctx), 16.dpToPx(ctx))
|
||||
clipToPadding = false
|
||||
}
|
||||
|
||||
// long-press drag to reorder the slots
|
||||
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 0
|
||||
) {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val from = viewHolder.adapterPosition
|
||||
val to = target.adapterPosition
|
||||
Collections.swap(items, from, to)
|
||||
slotAdapter.notifyItemMoved(from, to)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||
}).attachToRecyclerView(recycler)
|
||||
|
||||
AlertDialog.Builder(ctx)
|
||||
.setTitle(R.string.settings_quick_reactions_title)
|
||||
.setMessage(R.string.settings_quick_reactions_dialog_hint)
|
||||
.setView(recycler)
|
||||
.setPositiveButton(R.string.button_save) { _, _ ->
|
||||
quickReactionsSubject.onNext(items.joinToString(",") { it.ifEmpty { "➕" } })
|
||||
}
|
||||
.setNegativeButton(R.string.button_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun showMmsSizePicker() = mmsSizeDialog.show(activity!!)
|
||||
|
||||
|
||||
@@ -99,6 +99,9 @@ class SettingsPresenter @Inject constructor(
|
||||
disposables += prefs.defaultReactionEmoji.asObservable()
|
||||
.subscribe { emoji -> newState { copy(defaultReactionEmoji = emoji) } }
|
||||
|
||||
disposables += prefs.quickReactions.asObservable()
|
||||
.subscribe { quick -> newState { copy(quickReactions = quick) } }
|
||||
|
||||
val textSizeLabels = context.resources.getStringArray(R.array.text_sizes)
|
||||
disposables += prefs.textSize.asObservable()
|
||||
.subscribe { textSize ->
|
||||
@@ -189,6 +192,9 @@ class SettingsPresenter @Inject constructor(
|
||||
|
||||
R.id.defaultReaction -> view.showDefaultReactionDialog(prefs.defaultReactionEmoji.get())
|
||||
|
||||
R.id.quickReactions -> view.showQuickReactionsDialog(
|
||||
prefs.quickReactions.get().split(",").map { it.trim() }.filter { it.isNotEmpty() })
|
||||
|
||||
R.id.delayed -> view.showDelayDurationDialog()
|
||||
|
||||
R.id.delivery -> prefs.delivery.set(!prefs.delivery.get())
|
||||
@@ -288,6 +294,11 @@ class SettingsPresenter @Inject constructor(
|
||||
.autoDisposable(view.scope())
|
||||
.subscribe()
|
||||
|
||||
view.quickReactionsChanged()
|
||||
.doOnNext(prefs.quickReactions::set)
|
||||
.autoDisposable(view.scope())
|
||||
.subscribe()
|
||||
|
||||
view.mmsSizeSelected()
|
||||
.autoDisposable(view.scope())
|
||||
.subscribe(prefs.mmsSize::set)
|
||||
|
||||
@@ -37,6 +37,7 @@ data class SettingsState(
|
||||
val unreadAtTopEnabled: Boolean = false,
|
||||
val signature: String = "",
|
||||
val defaultReactionEmoji: String = Preferences.DEFAULT_REACTION_EMOJI,
|
||||
val quickReactions: String = Preferences.DEFAULT_QUICK_REACTIONS,
|
||||
val textSizeSummary: String = "",
|
||||
val textSizeId: Int = Preferences.TEXT_SIZE_NORMAL,
|
||||
val systemFontEnabled: Boolean = false,
|
||||
|
||||
@@ -33,6 +33,7 @@ interface SettingsView : QkViewContract<SettingsState> {
|
||||
fun sendDelaySelected(): Observable<Int>
|
||||
fun signatureChanged(): Observable<String>
|
||||
fun defaultReactionChanged(): Observable<String>
|
||||
fun quickReactionsChanged(): Observable<String>
|
||||
fun mmsSizeSelected(): Observable<Int>
|
||||
fun messageLinkHandlingSelected(): Observable<Int>
|
||||
|
||||
@@ -44,6 +45,7 @@ interface SettingsView : QkViewContract<SettingsState> {
|
||||
fun showDelayDurationDialog()
|
||||
fun showSignatureDialog(signature: String)
|
||||
fun showDefaultReactionDialog(current: String)
|
||||
fun showQuickReactionsDialog(current: List<String>)
|
||||
fun showMmsSizePicker()
|
||||
fun showMessageLinkHandlingDialogPicker()
|
||||
fun showSwipeActions()
|
||||
|
||||
@@ -166,6 +166,14 @@
|
||||
app:icon="@drawable/ic_favorite_black_24dp"
|
||||
app:title="@string/settings_default_reaction_title" />
|
||||
|
||||
<dev.octoshrimpy.quik.common.widget.PreferenceView
|
||||
android:id="@+id/quickReactions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:icon="@drawable/ic_favorite_black_24dp"
|
||||
app:summary="@string/settings_quick_reactions_summary"
|
||||
app:title="@string/settings_quick_reactions_title" />
|
||||
|
||||
<dev.octoshrimpy.quik.common.widget.PreferenceView
|
||||
android:id="@+id/delivery"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -312,6 +312,9 @@
|
||||
<item>Speak</item>
|
||||
</string-array>
|
||||
<string name="settings_default_reaction_title">Default reaction</string>
|
||||
<string name="settings_quick_reactions_title">Quick reactions</string>
|
||||
<string name="settings_quick_reactions_summary">Emoji shown in the reaction popup</string>
|
||||
<string name="settings_quick_reactions_dialog_hint">Tap to change, drag to reorder</string>
|
||||
<string name="settings_message_swipe_actions">Message swipe actions</string>
|
||||
<string name="settings_message_swipe_actions_summary">Configure swipe actions for messages</string>
|
||||
<string-array name="settings_message_swipe_actions">
|
||||
|
||||
Reference in New Issue
Block a user