package jp.juggler.subwaytooter.dialog import android.app.Dialog import android.graphics.Rect import android.graphics.drawable.PictureDrawable import android.text.Editable import android.text.TextWatcher import android.util.TypedValue import android.view.* import android.widget.ImageButton import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatButton import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.ContextCompat import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration import com.bumptech.glide.Glide import com.google.android.flexbox.FlexboxLayout import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.databinding.EmojiPickerDialogBinding import jp.juggler.subwaytooter.drawable.MediaBackgroundDrawable import jp.juggler.subwaytooter.emoji.* import jp.juggler.subwaytooter.global.appPref import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefS import jp.juggler.subwaytooter.pref.put import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.minHeightCompat import jp.juggler.subwaytooter.util.minWidthCompat import jp.juggler.subwaytooter.view.NetworkEmojiView import jp.juggler.util.* import kotlin.math.abs import kotlin.math.sign private class EmojiPicker( private val activity: AppCompatActivity, private val accessInfo: SavedAccount?, private val closeOnSelected: Boolean, private val onPicked: (EmojiBase, bInstanceHasCustomEmoji: Boolean) -> Unit, ) { companion object { private val log = LogCategory("EmojiPicker") private const val VT_CATEGORY = 0 private const val VT_CUSTOM_EMOJI = 1 private const val VT_TWEMOJI = 2 private const val VT_COMPAT_EMOJI = 3 private const val VT_SPACE = 4 private const val gridCols = 6 private fun EmojiCategory.getUnicodeEmojis() = emojiList.map { PickerItemUnicode(unicodeEmoji = it) } } private class SkinTone(val codeInt: Int) { val code = StringBuilder().apply { appendCodePoint(codeInt) }.toString() } private sealed interface PickerItem private object PickerItemSpace : PickerItem private class PickerItemUnicode(val unicodeEmoji: UnicodeEmoji) : PickerItem private class PickerItemCustom(val customEmoji: CustomEmoji) : PickerItem private open class PickerItemCategory( var name: String, val category: EmojiCategory, ) : PickerItem { val items = ArrayList() open fun createFiltered(keywordLower: String?) = PickerItemCategory(name = name, category = category).also { dst -> dst.items.addAll( if (keywordLower.isNullOrEmpty()) { items } else { items.filter { when (it) { is PickerItemCustom -> it.customEmoji.shortcode.contains(keywordLower) is PickerItemUnicode -> it.unicodeEmoji.namesLower.any { n -> n.contains(keywordLower) } else -> false } } } ) } } private class PickerItemCategoryRecent( name: String, val accessInfo: SavedAccount?, ) : PickerItemCategory(name, EmojiCategory.Recent) { private val recentsJsonList: List? get() = try { PrefS.spEmojiPickerRecent().decodeJsonArray().objectList() } catch (ex: Throwable) { log.w(ex, "can't load spEmojiPickerRecent") null } private fun JsonObject.parseRecent1( customEmojiMap: HashMap?, ): PickerItem? { val name = string("name") val instance = string("instance") try { name ?: error("missing emoji name") when (instance) { null -> EmojiMap.shortNameMap[name]?.let { return PickerItemUnicode(unicodeEmoji = it) } accessInfo?.apiHost?.ascii -> customEmojiMap?.get(name)?.let { return PickerItemCustom(customEmoji = it) } } } catch (ex: Throwable) { log.w(ex, "can't add emoji. $name, $instance") } return null } private fun List.loadRecents() { try { val customEmojiMap = accessInfo?.let { App1.custom_emoji_lister.getMapNonBlocking(it) } val newItems = mapNotNull { it.parseRecent1(customEmojiMap) } items.clear() items.addAll(newItems) } catch (ex: Throwable) { log.w(ex, "loadRecents failed.") } } // 最近使用した絵文字のPickerCategoryを作る fun load() = recentsJsonList?.loadRecents() fun update( targetName: String, targetInstance: String?, ) { // Recentをロード(他インスタンスの絵文字を含む) val list = recentsJsonList?.toMutableList() ?: ArrayList() // 選択された絵文字と同じ項目を除去 // 項目が増えすぎたら減らす // ユニコード絵文字256個、カスタム絵文字はインスタンス別256個まで var nCount = 0 val it = list.iterator() while (it.hasNext()) { val item = it.next() val itemName = item.string("name") val itemInstance = item.string("instance") if (itemInstance != targetInstance) continue if (itemName == targetName || ++nCount >= 256) { it.remove() } } // 先頭に項目を追加 list.add(0, JsonObject().apply { put("name", targetName) targetInstance?.let { put("instance", it) } }) // 保存する try { val sv = list.toJsonArray().toString() appPref.edit().put(PrefS.spEmojiPickerRecent, sv).apply() } catch (ex: Throwable) { log.e(ex, "can't save spEmojiPickerRecent") } // カテゴリ内のPickerItemの更新 try { list.loadRecents() } catch (ex: Throwable) { log.e(ex, "loadRecents failed.") } } } private sealed class ViewHolderBase(view: View) : RecyclerView.ViewHolder(view) { abstract fun bind(item: PickerItem) } private inner class VhCategory( val view: AppCompatTextView = AppCompatTextView(activity), ) : ViewHolderBase(view) { init { view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize) view.gravity = Gravity.START or Gravity.CENTER_VERTICAL view.includeFontPadding = false } override fun bind(item: PickerItem) { if (item is PickerItemCategory) { view.text = item.name } } } private inner class VhSpace( view: View = View(activity), ) : ViewHolderBase(view) { init { view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize) } override fun bind(item: PickerItem) { } } private inner class VhCustomEmoji( val view: NetworkEmojiView = NetworkEmojiView(activity), ) : ViewHolderBase(view) { init { view.setButtonBackground() view.setOnClickListener(pickerItemClickListener) view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize) } override fun bind(item: PickerItem) { if (activity.isDestroyed) return if (item is PickerItemCustom) { view.setTag(R.id.btnAbout, item) view.setEmoji( if (disableAnimation) { item.customEmoji.staticUrl } else { item.customEmoji.url } ) } } } private inner class VhTwemoji( val view: AppCompatImageView = AppCompatImageView(activity), ) : ViewHolderBase(view) { init { view.setButtonBackground() view.setOnClickListener(pickerItemClickListener) view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize) view.scaleType = ImageView.ScaleType.FIT_CENTER } override fun bind(item: PickerItem) { if (activity.isDestroyed) return if (item is PickerItemUnicode) { view.setTag(R.id.btnAbout, item) val emoji = applySkinTone(item.unicodeEmoji) if (emoji.isSvg) { Glide.with(activity) .`as`(PictureDrawable::class.java) .load("file:///android_asset/${emoji.assetsName}") .into(view) } else { Glide.with(activity) .load(emoji.drawableId) .into(view) } } } } private inner class VhAppCompatEmoji( val view: AppCompatTextView = AppCompatTextView(activity), ) : ViewHolderBase(view) { init { view.setButtonBackground() view.setOnClickListener(pickerItemClickListener) view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize) view.gravity = Gravity.CENTER view.setLineSpacing(0f, 0f) view.setTextSize(TypedValue.COMPLEX_UNIT_PX, gridSize.toFloat() * 0.7f) view.includeFontPadding = false } override fun bind(item: PickerItem) { if (activity.isDestroyed) return if (item is PickerItemUnicode) { view.setTag(R.id.btnAbout, item) val unicodeEmoji = applySkinTone(item.unicodeEmoji) view.text = unicodeEmoji.unifiedCode } } } class GridDecoration(private val space: Int) : ItemDecoration() { override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State, ) { outRect.left = space outRect.right = space outRect.bottom = space // Add top margin only for the first item to avoid double space between items outRect.top = if (parent.getChildLayoutPosition(view) == 0) { space } else { 0 } } } private inner class GridAdapter : RecyclerView.Adapter() { var list: List = emptyList() set(value) { field = value @Suppress("NotifyDataSetChanged") notifyDataSetChanged() } val spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int) = when { list[position] is PickerItemCategory -> gridCols else -> 1 } } override fun getItemCount() = list.size override fun getItemViewType(position: Int) = when (list[position]) { is PickerItemSpace -> VT_SPACE is PickerItemCategory -> VT_CATEGORY is PickerItemCustom -> VT_CUSTOM_EMOJI is PickerItemUnicode -> when { useTwemoji -> VT_TWEMOJI else -> VT_COMPAT_EMOJI } } override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int) = when (viewType) { VT_CATEGORY -> VhCategory() VT_CUSTOM_EMOJI -> VhCustomEmoji() VT_TWEMOJI -> VhTwemoji() VT_COMPAT_EMOJI -> VhAppCompatEmoji() VT_SPACE -> VhSpace() else -> error("unknown viewType=$viewType") } override fun onBindViewHolder(viewHolder: ViewHolderBase, position: Int) { viewHolder.bind(list[position]) } } private enum class FlickStatus { None, Start, Intercepted, } private lateinit var pickerCategries: List private val adapter = GridAdapter() private val views = EmojiPickerDialogBinding.inflate(activity.layoutInflater) private val ibSkinTone = listOf( Pair(R.id.btnSkinTone0, 0), Pair(R.id.btnSkinTone1, 0x1F3FB), Pair(R.id.btnSkinTone2, 0x1F3FC), Pair(R.id.btnSkinTone3, 0x1F3FD), Pair(R.id.btnSkinTone4, 0x1F3FE), Pair(R.id.btnSkinTone5, 0x1F3FF), ).map { (btnId, skinToneCode) -> views.root.findViewById(btnId).apply { tag = SkinTone(skinToneCode) setOnClickListener { selectedTone = (it.tag as SkinTone) showSkinTone() @Suppress("NotifyDataSetChanged") adapter.notifyDataSetChanged() } } } private val gridSize = (0.5f + 48f * activity.resources.displayMetrics.density).toInt() private val matchParent = RecyclerView.LayoutParams.MATCH_PARENT private val useTwemoji = PrefB.bpUseTwemoji() private val disableAnimation = PrefB.bpDisableEmojiAnimation() private var selectedTone: SkinTone = (ibSkinTone[0].tag as SkinTone) private lateinit var dialog: Dialog private var bInstanceHasCustomEmoji = false private var lastSelectedCategory: EmojiCategory? = null private var lastSelectedKeyword: String? = null private var recentCategory: PickerItemCategoryRecent? = null private val density = activity.resources.displayMetrics.density private val cancelY = 16f private val interceptX = 40f private var tracker: VelocityTracker? = null private var dragging = FlickStatus.None private var startX = 0f private var startY = 0f private val textWatcher = object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit override fun afterTextChanged(s: Editable?) { showFiltered(null, s?.toString()) } } private val pickerItemClickListener = View.OnClickListener { v -> val targetEmoji: EmojiBase val targetName: String val targetInstance: String? when (val item = v.getTag(R.id.btnAbout)) { is PickerItemUnicode -> { targetEmoji = applySkinTone(item.unicodeEmoji) targetName = targetEmoji.unifiedName targetInstance = null } is PickerItemCustom -> { targetEmoji = item.customEmoji targetName = item.customEmoji.shortcode targetInstance = accessInfo!!.apiHost.ascii } else -> return@OnClickListener } recentCategory?.update(targetName, targetInstance) if (closeOnSelected) { dialog.dismissSafe() } else if (lastSelectedCategory == null || lastSelectedCategory == EmojiCategory.Recent) { // 全カテゴリ表示や最近の表示は最近の絵文字の順序を変えるため更新してしまう showFiltered(lastSelectedCategory, lastSelectedKeyword) // XXX: タップ状態の表示が行えない… } else { // この場合はビューの更新は不要で、タップ状態の表示を行える } onPicked(targetEmoji, bInstanceHasCustomEmoji) } private suspend fun createCustomEmojiCategories(): List { accessInfo ?: error("missing accessInfo") val context = activity val srcList = App1.custom_emoji_lister.getList(accessInfo) val nameMap = HashMap() for (emoji in srcList) { if (!emoji.visibleInPicker) continue val categoryName = emoji.category ?: "" (nameMap[categoryName] ?: PickerItemCategory( name = categoryName, category = EmojiCategory.Custom, ).also { nameMap[categoryName] = it }) .items.add(PickerItemCustom(emoji)) } val otherCategory = nameMap[""] // カテゴリ名の頭に「カスタム」を追加 return nameMap.values.onEach { it.name = when (it) { otherCategory -> when (nameMap.size) { 0 -> context.getString(R.string.emoji_category_custom) else -> context.getString( R.string.emoji_picker_custom_of, context.getString(R.string.others) ) } else -> context.getString(R.string.emoji_picker_custom_of, it.name) } }.sortedWith { l, r -> if (l == otherCategory) 1 else if (r == otherCategory) -1 else l.name.compareTo(r.name) } } private suspend fun buildCategoryList() = buildList { // 最近使った絵文字 PickerItemCategoryRecent( name = activity.getString(R.string.emoji_category_recent), accessInfo = accessInfo, ).also { recentCategory = it it.load() add(it) } // カスタム絵文字 try { addAll(createCustomEmojiCategories()) } catch (ex: Throwable) { log.w(ex, "createCustomEmojiCategories failed.") } val categories = mutableListOf( EmojiCategory.People, EmojiCategory.ComplexTones, EmojiCategory.Nature, EmojiCategory.Foods, EmojiCategory.Activities, EmojiCategory.Places, EmojiCategory.Objects, EmojiCategory.Symbols, EmojiCategory.Flags, ) if (PrefB.bpEmojiPickerCategoryOther(activity)) { categories.add(EmojiCategory.Others) } for (category in categories) { val pc = PickerItemCategory( category = category, name = activity.getString(category.titleId), ) pc.items.addAll(category.getUnicodeEmojis()) add(pc) } } private fun applySkinTone(emojiArg: UnicodeEmoji): UnicodeEmoji { // トーン指定がないなら元のコード val selectedTone = selectedTone.takeIf { it.codeInt > 0 } ?: return emojiArg var emoji = emojiArg // Recentなどでは既にsuffixがついた名前が用意されている // suffixを除去する emoji.toneParent?.let { emoji = it } // 指定したトーンのサフィックスを追加して、絵文字が存在すればその名前にする emoji.toneChildren.find { it.first == selectedTone.code } ?.let { return it.second } // なければトーンなしの絵文字 return emoji } private fun showSkinTone() { ibSkinTone.forEach { when (selectedTone) { it.tag -> it.setImageResource(R.drawable.check_mark) else -> it.setImageDrawable(null) } } } private fun showFiltered( selectedCategory: EmojiCategory?, selectedKeyword: String?, ) { lastSelectedCategory = selectedCategory lastSelectedKeyword = selectedKeyword adapter.list = buildList { val keywordLower = selectedKeyword?.lowercase()?.trim() pickerCategries.filter { selectedCategory == null || it.category == selectedCategory }.mapNotNull { category -> category.createFiltered(keywordLower) .takeIf { it.items.isNotEmpty() } }.forEach { if( it.category == EmojiCategory.Custom || selectedCategory == null) add(it) addAll(it.items) val mod = it.items.size % gridCols if (mod > 0) { repeat(gridCols - mod) { add(PickerItemSpace) } } } } for (it in views.llCategories.children) { val backgroundId = when (it.tag) { selectedCategory -> R.drawable.bg_button_cw else -> R.drawable.btn_bg_transparent_round6dp } it.background = ContextCompat.getDrawable(it.context, backgroundId) if (it.tag == selectedCategory) { val oldScrollX = views.svCategories.scrollX val visibleWidth = views.svCategories.width log.i("left=${it.left},r=${it.right},s=$oldScrollX") when { oldScrollX > it.left -> views.svCategories.smoothScrollTo(it.left, 0) oldScrollX + visibleWidth < it.right -> views.svCategories.smoothScrollTo(it.right - visibleWidth, 0) } } } } private fun addCategoryButton(category: EmojiCategory) { val density = activity.resources.displayMetrics.density val wrapContent = FlexboxLayout.LayoutParams.WRAP_CONTENT val minWidth = (density * 48f + 0.5f).toInt() val padTb = (density * 4f + 0.5f).toInt() val padLr = (density * 6f + 0.5f).toInt() AppCompatButton(activity).apply { tag = category layoutParams = FlexboxLayout.LayoutParams(wrapContent, wrapContent) background = ContextCompat.getDrawable(context, R.drawable.btn_bg_transparent_round6dp) minWidthCompat = minWidth minHeightCompat = minWidth setPadding(padLr, padTb, padLr, padTb) text = activity?.getString(category.titleId) setOnClickListener { views.etFilter.removeTextChangedListener(textWatcher) views.etFilter.setText("") views.etFilter.addTextChangedListener(textWatcher) showFiltered(category, null) } }.let { views.llCategories.addView(it) } } private fun movePage(delta: Int) { val categories = buildList { addAll(pickerCategries.map { it.category }.distinct().sorted()) } val newIndex = when ( val oldIndex = categories.indexOfFirst { it == lastSelectedCategory } ) { -1 -> if (delta > 0) 0 else categories.size - 1 else -> (oldIndex + categories.size + delta) % categories.size } val newCategory = categories[newIndex] showFiltered(newCategory, null) } private fun View.setButtonBackground() { background = ContextCompat.getDrawable(context, R.drawable.btn_bg_transparent_round6dp) } private fun handleTouch(ev: MotionEvent, wasIntercept: Boolean) = try { when (ev.actionMasked) { MotionEvent.ACTION_CANCEL -> { log.i("ACTION_CANCEL wasIntercept=$wasIntercept") dragging = FlickStatus.None wasIntercept } MotionEvent.ACTION_UP -> { try { log.i("ACTION_UP wasIntercept=$wasIntercept") if (dragging == FlickStatus.Intercepted) { tracker?.let { it.addMovement(ev) it.computeCurrentVelocity(1000) val vx = it.xVelocity val vy = it.yVelocity val vxDp = vx / density val aspect = abs(vx) / abs(vy) log.i("vx=$vx vy=$vy") if (aspect < 2f) { log.i("not gesture: aspect=$aspect") } else if (abs(vxDp) < 40f) { log.i("not gesture: vxDp=$vxDp") } else { movePage((vxDp.sign * -1f).toInt()) } } } } finally { dragging = FlickStatus.None } wasIntercept } MotionEvent.ACTION_DOWN -> { log.i("ACTION_DOWN wasIntercept=$wasIntercept") // ドラッグ開始 dragging = FlickStatus.Start if (tracker == null) { tracker = VelocityTracker.obtain() } tracker?.clear() startX = ev.x startY = ev.y wasIntercept } MotionEvent.ACTION_MOVE -> { if (dragging == FlickStatus.None) { wasIntercept } else { // 移動量追跡 tracker?.addMovement(ev) val deltaX = abs(ev.x - startX) / density val deltaY = abs(ev.y - startY) / density when { // すでにインターセプトしている wasIntercept -> true // 上下方向に大きく動かしたらそれ以上追跡しない deltaY >= cancelY -> { log.i("not flick! $deltaY") dragging = FlickStatus.None false } // 横方向に大きく動かしたらインターセプトする deltaX >= interceptX && deltaX > deltaY -> { log.i("intercept! $deltaX") dragging = FlickStatus.Intercepted true } else -> { log.d("not yet intercept. $deltaX, $deltaY") false } } } } else -> log.w("handleTouch else $ev") } } catch (ex: Throwable) { log.e(ex, "handleTouch failed. ev=$ev, wasIntercept=$wasIntercept") wasIntercept } suspend fun start() { pickerCategries = buildCategoryList() bInstanceHasCustomEmoji = pickerCategries.any { it.category == EmojiCategory.Custom } pickerCategries.map { it.category }.distinct().sorted().forEach { addCategoryButton(it) } views.etFilter.addTextChangedListener(textWatcher) showFiltered(null, null) val density = activity.resources.displayMetrics.density views.giGrid.intercept = { handleTouch(it, wasIntercept = false) } views.giGrid.touch = { handleTouch(it, wasIntercept = true) } views.rvGrid.adapter = adapter views.rvGrid.layoutManager = GridLayoutManager( activity, gridCols, RecyclerView.VERTICAL, false ).also { it.spanSizeLookup = adapter.spanSizeLookup } val cellSpacing = (density * 1f + 0.5f).toInt() views.rvGrid.addItemDecoration(GridDecoration(cellSpacing)) showSkinTone() this.dialog = Dialog(activity) dialog.setContentView(views.root) dialog.setCancelable(true) dialog.setCanceledOnTouchOutside(true) dialog.setOnDismissListener { tracker?.recycle() } dialog.window?.let { w -> @Suppress("DEPRECATION") w.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN ) } dialog.show() } } fun launchEmojiPicker( activity: AppCompatActivity, accessInfo: SavedAccount?, closeOnSelected: Boolean, onPicked: (EmojiBase, bInstanceHasCustomEmoji: Boolean) -> Unit, ) = activity.launchAndShowError { EmojiPicker( activity = activity, accessInfo = accessInfo, closeOnSelected = closeOnSelected, onPicked = onPicked, ).start() }