1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

Feature/emoji picker search (#549)

This commit is contained in:
Evgenii Kozlov 2020-06-26 13:03:40 +03:00 committed by GitHub
parent bce7fbbfd5
commit 458668d0d7
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 32969 additions and 16181 deletions

View file

@ -151,7 +151,6 @@ open class EditorTestSetup {
documentEventReducer = DocumentExternalEventReducer(),
archiveDocument = archiveDocument,
createDocument = createDocument,
uploadUrl = uploadBlock,
urlBuilder = urlBuilder,
renderer = DefaultBlockViewRenderer(
urlBuilder = urlBuilder,
@ -187,7 +186,8 @@ open class EditorTestSetup {
proxies = proxies,
stores = stores,
matcher = DefaultPatternMatcher()
)
),
uploadBlock = uploadBlock
)
)
}

View file

@ -4,13 +4,11 @@ import com.agileburo.anytype.presentation.page.PageViewModelFactory
import com.agileburo.anytype.ui.page.PageFragment
class TestPageFragment : PageFragment() {
init {
this.factory =
testViewModelFactory
factory = testViewModelFactory
}
override fun injectDependencies() {}
override fun releaseDependencies() {}
companion object {
lateinit var testViewModelFactory: PageViewModelFactory

View file

@ -0,0 +1,333 @@
package com.agileburo.anytype.features.emoji
import android.os.Bundle
import android.view.KeyEvent
import androidx.core.os.bundleOf
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import com.agileburo.anytype.R
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.icon.SetDocumentEmojiIcon
import com.agileburo.anytype.emojifier.data.Emoji
import com.agileburo.anytype.emojifier.data.EmojiProvider
import com.agileburo.anytype.emojifier.suggest.EmojiSuggester
import com.agileburo.anytype.emojifier.suggest.model.EmojiModel
import com.agileburo.anytype.mocking.MockDataFactory
import com.agileburo.anytype.presentation.page.picker.DocumentEmojiIconPickerViewModelFactory
import com.agileburo.anytype.utils.TestUtils.withRecyclerView
import com.nhaarman.mockitokotlin2.*
import kotlinx.android.synthetic.main.fragment_page_icon_picker.*
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
@LargeTest
class DocumentEmojiPickerFragmentTest {
@Mock
lateinit var suggester: EmojiSuggester
@Mock
lateinit var provider: EmojiProvider
@Mock
lateinit var repo: BlockRepository
private lateinit var setEmojiIcon: SetDocumentEmojiIcon
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
setEmojiIcon = SetDocumentEmojiIcon(repo = repo)
TestDocumentEmojiPickerFragment.testViewModelFactory =
DocumentEmojiIconPickerViewModelFactory(
emojiProvider = provider,
emojiSuggester = suggester,
setEmojiIcon = setEmojiIcon
)
}
@Test
fun shouldHaveNothingVisible() {
// SETUP
provider.stub {
on { emojis } doReturn emptyArray()
}
launchFragment(bundleOf())
// TESTING
onView(withId(R.id.clearSearchText)).apply {
check(matches(not(isDisplayed())))
}
onView(withId(R.id.progressBar)).apply {
check(matches(not(isDisplayed())))
}
onView(withId(R.id.pickerRecycler)).apply {
check(matches(isDisplayed()))
check(matches(hasChildCount(0)))
}
}
@Test
fun shouldHaveHeaderAndSixVisibleEmojis() {
// SETUP
val emojis = arrayOf(
arrayOf(
"😀",
"😃",
"😄",
"😁",
"😆",
"😅"
)
)
provider.stub {
on { provider.emojis } doReturn emojis
}
launchFragment(bundleOf())
// TESTING
onView(withId(R.id.clearSearchText)).apply {
check(matches(not(isDisplayed())))
}
onView(withId(R.id.progressBar)).apply {
check(matches(not(isDisplayed())))
}
onView(withId(R.id.pickerRecycler)).apply {
check(matches(isDisplayed()))
check(matches(hasChildCount(emojis.first().size.inc())))
}
onView(withRecyclerView(R.id.pickerRecycler).atPositionOnView(0, R.id.category)).apply {
check(matches(withText(R.string.category_smileys_and_people)))
}
}
@Test
fun shouldFindOnlyOneDogEmoji() {
// SETUP
val query = "dog"
val dog = "🐶"
val emojis = arrayOf(
Emoji.DATA[0].take(6).toTypedArray(),
Emoji.DATA[1].take(6).toTypedArray(),
Emoji.DATA[2].take(6).toTypedArray(),
Emoji.DATA[3].take(6).toTypedArray(),
Emoji.DATA[4].take(6).toTypedArray(),
Emoji.DATA[5].take(6).toTypedArray(),
Emoji.DATA[6].take(6).toTypedArray(),
Emoji.DATA[7].take(6).toTypedArray()
)
provider.stub {
on { provider.emojis } doReturn emojis
}
suggester.stub {
onBlocking { search(query) } doReturn listOf(
EmojiModel(
category = MockDataFactory.randomString(),
name = MockDataFactory.randomString(),
emoji = dog
)
)
}
launchFragment(bundleOf())
// TESTING
onView(withId(R.id.filterInputField)).apply {
perform(click())
perform(typeText(query))
}
// Verifying results
Thread.sleep(500)
onView(withId(R.id.pickerRecycler)).apply {
check(matches(hasChildCount(1)))
}
verifyBlocking(suggester, times(1)) { search(any()) }
}
@Test
fun shouldNotSearchIfTextIsCleared() {
// SETUP
val query = "dog"
val dog = "🐶"
val emojis = arrayOf(
Emoji.DATA[0].take(6).toTypedArray(),
Emoji.DATA[1].take(6).toTypedArray(),
Emoji.DATA[2].take(6).toTypedArray(),
Emoji.DATA[3].take(6).toTypedArray(),
Emoji.DATA[4].take(6).toTypedArray(),
Emoji.DATA[5].take(6).toTypedArray(),
Emoji.DATA[6].take(6).toTypedArray(),
Emoji.DATA[7].take(6).toTypedArray()
)
provider.stub {
on { provider.emojis } doReturn emojis
}
suggester.stub {
onBlocking { search(query) } doReturn listOf(
EmojiModel(
category = MockDataFactory.randomString(),
name = MockDataFactory.randomString(),
emoji = dog
)
)
}
val scenario = launchFragment(bundleOf())
// TESTING
onView(withId(R.id.filterInputField)).apply {
perform(click())
perform(typeText(query))
}
Thread.sleep(500)
onView(withId(R.id.pickerRecycler)).apply {
check(matches(hasChildCount(1)))
}
// Clearing text after first query
onView(withId(R.id.clearSearchText)).apply {
perform(click())
perform(closeSoftKeyboard())
}
// Verifying results
Thread.sleep(500)
scenario.onFragment { fragment ->
val count = fragment.pickerRecycler.adapter?.itemCount
assertEquals(
expected = (6 * 8) + 8,
actual = count
)
}
verifyBlocking(suggester, times(1)) { search(any()) }
}
@Test
fun shouldNotSearchIfTextIsDeletedByBackspace() {
// SETUP
val query = "dog"
val dog = "🐶"
val emojis = arrayOf(
Emoji.DATA[0].take(6).toTypedArray(),
Emoji.DATA[1].take(6).toTypedArray(),
Emoji.DATA[2].take(6).toTypedArray(),
Emoji.DATA[3].take(6).toTypedArray(),
Emoji.DATA[4].take(6).toTypedArray(),
Emoji.DATA[5].take(6).toTypedArray(),
Emoji.DATA[6].take(6).toTypedArray(),
Emoji.DATA[7].take(6).toTypedArray()
)
provider.stub {
on { provider.emojis } doReturn emojis
}
suggester.stub {
onBlocking { search(query) } doReturn listOf(
EmojiModel(
category = MockDataFactory.randomString(),
name = MockDataFactory.randomString(),
emoji = dog
)
)
}
val scenario = launchFragment(bundleOf())
// TESTING
onView(withId(R.id.filterInputField)).apply {
perform(click())
perform(typeText(query))
}
Thread.sleep(500)
onView(withId(R.id.pickerRecycler)).apply {
check(matches(hasChildCount(1)))
}
// Removing text after first query
onView(withId(R.id.filterInputField)).apply {
repeat(query.length) { perform(pressKey(KeyEvent.KEYCODE_DEL)) }
perform(closeSoftKeyboard())
}
// Verifying results
Thread.sleep(500)
scenario.onFragment { fragment ->
val count = fragment.pickerRecycler.adapter?.itemCount
assertEquals(
expected = (6 * 8) + 8,
actual = count
)
}
verifyBlocking(suggester, times(1)) { search(query) }
verifyBlocking(suggester, times(1)) { search(any()) }
}
private fun launchFragment(args: Bundle): FragmentScenario<TestDocumentEmojiPickerFragment> {
return launchFragmentInContainer<TestDocumentEmojiPickerFragment>(
fragmentArgs = args,
themeResId = R.style.AppTheme
)
}
}

View file

@ -0,0 +1,17 @@
package com.agileburo.anytype.features.emoji
import com.agileburo.anytype.presentation.page.picker.DocumentEmojiIconPickerViewModelFactory
import com.agileburo.anytype.ui.page.modals.DocumentEmojiIconPickerFragment
class TestDocumentEmojiPickerFragment : DocumentEmojiIconPickerFragment() {
init {
factory = testViewModelFactory
}
override fun injectDependencies() {}
override fun releaseDependencies() {}
companion object {
lateinit var testViewModelFactory: DocumentEmojiIconPickerViewModelFactory
}
}

32178
app/src/main/assets/emoji.json Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@ package com.agileburo.anytype.di.feature
import com.agileburo.anytype.core_utils.di.scope.PerScreen
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.icon.SetDocumentEmojiIcon
import com.agileburo.anytype.emojifier.data.Emoji
import com.agileburo.anytype.emojifier.suggest.EmojiSuggester
import com.agileburo.anytype.presentation.page.picker.DocumentEmojiIconPickerViewModelFactory
import com.agileburo.anytype.ui.page.modals.DocumentEmojiIconPickerFragment
import dagger.Module
@ -28,9 +30,12 @@ class DocumentEmojiIconPickerModule {
@Provides
@PerScreen
fun provideDocumentEmojiIconPickerViewModel(
setEmojiIcon: SetDocumentEmojiIcon
setEmojiIcon: SetDocumentEmojiIcon,
emojiSuggester: EmojiSuggester
): DocumentEmojiIconPickerViewModelFactory = DocumentEmojiIconPickerViewModelFactory(
setEmojiIcon = setEmojiIcon
setEmojiIcon = setEmojiIcon,
emojiSuggester = emojiSuggester,
emojiProvider = Emoji
)
@Provides

View file

@ -1,7 +1,13 @@
package com.agileburo.anytype.di.main
import com.agileburo.anytype.domain.emoji.Emojifier
import com.agileburo.anytype.emojifier.DefaultEmojifier
import android.content.Context
import com.agileburo.anytype.emojifier.suggest.EmojiSuggester
import com.agileburo.anytype.emojifier.suggest.data.DefaultEmojiSuggestStorage
import com.agileburo.anytype.emojifier.suggest.data.DefaultEmojiSuggester
import com.agileburo.anytype.emojifier.suggest.data.EmojiSuggestStorage
import com.agileburo.anytype.emojifier.suggest.data.EmojiSuggesterCache
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@ -11,5 +17,25 @@ class EmojiModule {
@Provides
@Singleton
fun provideEmojifier(): Emojifier = DefaultEmojifier()
fun provideEmojiSuggester(
cache: EmojiSuggesterCache,
storage: EmojiSuggestStorage
): EmojiSuggester {
return DefaultEmojiSuggester(
cache = cache,
storage = storage
)
}
@Provides
@Singleton
fun provideEmojiSuggesterCache(): EmojiSuggesterCache {
return EmojiSuggesterCache.DefaultCache()
}
@Provides
@Singleton
fun provideEmojiSuggestStorage(context: Context): EmojiSuggestStorage {
return DefaultEmojiSuggestStorage(context, Gson())
}
}

View file

@ -7,12 +7,14 @@ import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.os.bundleOf
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProviders
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.agileburo.anytype.R
import com.agileburo.anytype.core_utils.ext.toast
import com.agileburo.anytype.core_utils.ext.invisible
import com.agileburo.anytype.core_utils.ext.visible
import com.agileburo.anytype.core_utils.ui.BaseBottomSheetFragment
import com.agileburo.anytype.di.common.componentManager
import com.agileburo.anytype.library_page_icon_picker_widget.ui.DocumentEmojiIconPickerAdapter
@ -27,7 +29,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
class DocumentEmojiIconPickerFragment : BaseBottomSheetFragment() {
open class DocumentEmojiIconPickerFragment : BaseBottomSheetFragment() {
private val target: String
get() = requireArguments()
@ -51,7 +53,6 @@ class DocumentEmojiIconPickerFragment : BaseBottomSheetFragment() {
private val emojiPickerAdapter by lazy {
DocumentEmojiIconPickerAdapter(
views = emptyList(),
onFilterQueryChanged = { toast("not implemented yet") },
onEmojiClicked = { unicode ->
vm.onEmojiClicked(
unicode = unicode,
@ -82,10 +83,15 @@ class DocumentEmojiIconPickerFragment : BaseBottomSheetFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecycler()
clearSearchText.setOnClickListener {
filterInputField.setText(EMPTY_FILTER_TEXT)
clearSearchText.invisible()
}
filterInputField.doAfterTextChanged { vm.onQueryChanged(it.toString()) }
}
private fun setupRecycler() {
recyler.apply {
pickerRecycler.apply {
setItemViewCacheSize(EMOJI_RECYCLER_ITEM_VIEW_CACHE_SIZE)
setHasFixedSize(true)
layoutManager = GridLayoutManager(context, PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT).apply {
@ -94,7 +100,6 @@ class DocumentEmojiIconPickerFragment : BaseBottomSheetFragment() {
when (val type = emojiPickerAdapter.getItemViewType(position)) {
DocumentEmojiIconPickerViewHolder.HOLDER_EMOJI_ITEM -> 1
DocumentEmojiIconPickerViewHolder.HOLDER_EMOJI_CATEGORY_HEADER -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
DocumentEmojiIconPickerViewHolder.HOLDER_EMOJI_FILTER -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
else -> throw IllegalStateException("$UNEXPECTED_VIEW_TYPE_MESSAGE: $type")
}
}
@ -107,7 +112,18 @@ class DocumentEmojiIconPickerFragment : BaseBottomSheetFragment() {
super.onActivityCreated(savedInstanceState)
vm.state().onEach { state ->
when (state) {
is ViewState.Success -> emojiPickerAdapter.update(state.views)
is ViewState.Success -> {
if (filterInputField.text.isNotEmpty())
clearSearchText.visible()
else
clearSearchText.invisible()
emojiPickerAdapter.update(state.views)
progressBar.invisible()
}
is ViewState.Loading -> {
clearSearchText.invisible()
progressBar.visible()
}
is ViewState.Exit -> dismiss()
}
}.launchIn(lifecycleScope)
@ -150,6 +166,7 @@ class DocumentEmojiIconPickerFragment : BaseBottomSheetFragment() {
)
}
private const val EMPTY_FILTER_TEXT = ""
private const val PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT = 6
private const val EMOJI_RECYCLER_ITEM_VIEW_CACHE_SIZE = 2000
private const val ARG_CONTEXT_ID_KEY = "arg.picker.context.id"

View file

@ -23,8 +23,75 @@
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:background="@drawable/page_icon_picker_filter_background">
<ImageView
android:id="@+id/searchIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:background="@drawable/ic_page_icon_picker_search"
android:contentDescription="@string/content_description_loop_icon"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/filterInputField"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:background="@null"
android:fontFamily="@font/inter_regular"
android:hint="@string/page_icon_picker_emoji_filter"
android:inputType="textNoSuggestions"
android:maxLines="1"
android:singleLine="true"
android:textSize="17sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/clearSearchText"
app:layout_constraintStart_toEndOf="@+id/searchIcon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:visibility="invisible"
android:id="@+id/clearSearchText"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_clear_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical"
android:layout_marginEnd="8dp"
android:theme="@style/GreyProgressBar"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyler"
android:id="@+id/pickerRecycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="20dp"

View file

@ -25,6 +25,10 @@
<item name="colorAccent">@color/white</item>
</style>
<style name="GreyProgressBar" parent="ThemeOverlay.AppCompat.Light">
<item name="colorAccent">#ACA996</item>
</style>
<style name="AppBottomSheetDialogTheme" parent="Theme.Design.Light.BottomSheetDialog">
<item name="bottomSheetStyle">@style/AppModalStyle</item>
</style>

View file

@ -8,6 +8,7 @@
android:paddingEnd="20dp">
<FrameLayout
android:background="@drawable/rectangle_default_page_logo_background"
android:transitionName="@string/logo_transition"
android:id="@+id/documentIconContainer"
android:layout_marginTop="45dp"

View file

@ -0,0 +1,14 @@
package com.agileburo.anytype.core_utils.ext
import android.content.Context
import java.io.IOException
fun Context.getJsonDataFromAsset(fileName: String): String? = try {
assets
.open(fileName)
.bufferedReader()
.use { stream -> stream.readText() }
} catch (e: IOException) {
e.printStackTrace()
null
}

View file

@ -1,9 +0,0 @@
package com.agileburo.anytype.domain.emoji
data class Emoji(
val unicode: String,
val alias: String
) {
val name: String
get() = ":$alias:"
}

View file

@ -1,6 +0,0 @@
package com.agileburo.anytype.domain.emoji
interface Emojifier {
suspend fun fromAlias(alias: String): Emoji
suspend fun fromShortName(name: String): Emoji
}

View file

@ -40,9 +40,18 @@ android {
}
dependencies {
implementation project(':domain')
def appDependencies = rootProject.ext.mainApplication
def unitTestDependencies = rootProject.ext.unitTesting
def lib = rootProject.ext.libraryPageIconPicker
implementation project(':domain')
implementation project(':core-utils')
implementation(lib.emojiJava) {
exclude group: 'org.json', module: 'json'
}
implementation appDependencies.gson
implementation appDependencies.coroutines
testImplementation unitTestDependencies.junit
}

View file

@ -1,39 +0,0 @@
package com.agileburo.anytype.emojifier
import com.agileburo.anytype.domain.emoji.Emoji
import com.agileburo.anytype.domain.emoji.Emojifier
import com.vdurmont.emoji.EmojiManager
class DefaultEmojifier : Emojifier {
override suspend fun fromAlias(alias: String): Emoji {
check(alias.isNotEmpty()) { "Alias cannot be empty" }
return EmojiManager.getForAlias(alias).let { result ->
Emoji(
/**
* Fix pirate flag emoji render, after fixing
* in table https://github.com/vdurmont/emoji-java/blob/master/EMOJIS.md
* can be removed
*/
unicode = result.unicode.filterTextByChar(
value = '☠',
filterBy = '♾'
),
alias = result.aliases.first()
)
}
}
override suspend fun fromShortName(name: String): Emoji {
check(name.isNotEmpty()) { "Short name cannot be empty" }
val alias = name.substring(1, name.length - 1)
return fromAlias(alias)
}
}
fun String.filterTextByChar(value: Char, filterBy: Char): String =
if (contains(value)) {
filterNot { it == filterBy }
} else {
this
}

View file

@ -1,5 +1,7 @@
package com.agileburo.anytype.emojifier
import com.agileburo.anytype.emojifier.data.Emoji
object Emojifier {
/**
@ -36,10 +38,10 @@ object Emojifier {
var result: Pair<Int, Int>? = null
Emoji.data.forEachIndexed { idx, category ->
val index = category.indexOfFirst { emoji -> emoji == unicode }
if (index != -1) {
val pair = Pair(idx, index)
Emoji.DATA.forEachIndexed { categoryIndex, emojis ->
val emojiIndex = emojis.indexOfFirst { emoji -> emoji == unicode }
if (emojiIndex != -1) {
val pair = Pair(categoryIndex, emojiIndex)
result = pair
cache[unicode] = pair
return@forEachIndexed
@ -47,4 +49,8 @@ object Emojifier {
}
return result ?: throw IllegalStateException("Result not found for: $unicode")
}
object Config {
const val EMOJI_FILE = "emoji.json"
}
}

View file

@ -1,8 +1,8 @@
package com.agileburo.anytype.emojifier
package com.agileburo.anytype.emojifier.data
object Emoji {
object Emoji : EmojiProvider {
val colors = listOf(
val COLORS = listOf(
"🏻",
"🏼",
"🏽",
@ -10,7 +10,7 @@ object Emoji {
"🏿"
)
val data: Array<Array<String>> = arrayOf(
val DATA: Array<Array<String>> = arrayOf(
arrayOf(
"😀",
"😃",
@ -3153,6 +3153,8 @@ object Emoji {
)
)
override val emojis: Array<Array<String>> get() = DATA
const val CATEGORY_SMILEYS_AND_PEOPLE = 0
const val CATEGORY_ANIMALS_AND_NATURE = 1
const val CATEGORY_FOOD_AND_DRINK = 2

View file

@ -0,0 +1,5 @@
package com.agileburo.anytype.emojifier.data
interface EmojiProvider {
val emojis: Array<Array<String>>
}

View file

@ -0,0 +1,16 @@
package com.agileburo.anytype.emojifier.suggest
import com.agileburo.anytype.emojifier.suggest.model.EmojiSuggest
interface EmojiSuggester {
/**
* @return a complete list of emoji suggests.
*/
suspend fun fetch(): List<EmojiSuggest>
/**
* @param query user-generated query (for searching emojis)
* @return a list of emoji suggests corresponding to this [query]
*/
suspend fun search(query: String): List<EmojiSuggest>
}

View file

@ -0,0 +1,23 @@
package com.agileburo.anytype.emojifier.suggest.data
import android.content.Context
import com.agileburo.anytype.core_utils.ext.getJsonDataFromAsset
import com.agileburo.anytype.emojifier.Emojifier.Config.EMOJI_FILE
import com.agileburo.anytype.emojifier.suggest.model.EmojiModel
import com.agileburo.anytype.emojifier.suggest.model.EmojiSuggest
import com.google.gson.Gson
class DefaultEmojiSuggestStorage(
private val context: Context,
private val gson: Gson
) : EmojiSuggestStorage {
override suspend fun fetch(): List<EmojiSuggest> {
val json = context.getJsonDataFromAsset(EMOJI_FILE)
return if (json != null) {
gson.fromJson(json, Array<EmojiModel>::class.java).toList()
} else {
emptyList()
}
}
}

View file

@ -0,0 +1,20 @@
package com.agileburo.anytype.emojifier.suggest.data
import com.agileburo.anytype.emojifier.suggest.EmojiSuggester
import com.agileburo.anytype.emojifier.suggest.model.EmojiSuggest
class DefaultEmojiSuggester(
private val cache: EmojiSuggesterCache,
private val storage: EmojiSuggestStorage
) : EmojiSuggester {
override suspend fun fetch(): List<EmojiSuggest> {
return cache.getIfPresent() ?: storage.fetch().also { cache.put(it) }
}
override suspend fun search(query: String): List<EmojiSuggest> {
return fetch().filter { value ->
value.name.contains(query, true) || value.category.contains(query, true)
}
}
}

View file

@ -0,0 +1,7 @@
package com.agileburo.anytype.emojifier.suggest.data
import com.agileburo.anytype.emojifier.suggest.model.EmojiSuggest
interface EmojiSuggestStorage {
suspend fun fetch(): List<EmojiSuggest>
}

View file

@ -0,0 +1,21 @@
package com.agileburo.anytype.emojifier.suggest.data
import com.agileburo.anytype.emojifier.suggest.model.EmojiSuggest
interface EmojiSuggesterCache {
suspend fun getIfPresent(): List<EmojiSuggest>?
suspend fun put(suggests: List<EmojiSuggest>)
class DefaultCache : EmojiSuggesterCache {
var list: List<EmojiSuggest>? = null
override suspend fun getIfPresent(): List<EmojiSuggest>? {
return list
}
override suspend fun put(suggests: List<EmojiSuggest>) {
list = suggests
}
}
}

View file

@ -0,0 +1,13 @@
package com.agileburo.anytype.emojifier.suggest.model
import com.google.gson.annotations.SerializedName
data class EmojiModel(
@SerializedName("category")
override val category: String,
@SerializedName("char")
override val emoji: String,
@SerializedName("name")
override val name: String
) : EmojiSuggest

View file

@ -0,0 +1,12 @@
package com.agileburo.anytype.emojifier.suggest.model
/**
* @property emoji emoji char
* @property name short name for this [emoji]
* @property category category for this [emoji]
*/
interface EmojiSuggest {
val emoji: String
val name: String
val category: String
}

View file

@ -2,12 +2,14 @@ package com.agileburo.anytype.library_page_icon_picker_widget.model
import com.agileburo.anytype.core_ui.common.ViewType
import com.agileburo.anytype.library_page_icon_picker_widget.ui.DocumentEmojiIconPickerViewHolder.Companion.HOLDER_EMOJI_CATEGORY_HEADER
import com.agileburo.anytype.library_page_icon_picker_widget.ui.DocumentEmojiIconPickerViewHolder.Companion.HOLDER_EMOJI_FILTER
import com.agileburo.anytype.library_page_icon_picker_widget.ui.DocumentEmojiIconPickerViewHolder.Companion.HOLDER_EMOJI_ITEM
sealed class EmojiPickerView : ViewType {
/**
* @property alias short name or convenient name for an emoji.
* @property page emoji's page (emoji category)
* @property index emoji's index on the [page]
* @property unicode emoji's char
*/
data class Emoji(
val unicode: String,
@ -25,11 +27,4 @@ sealed class EmojiPickerView : ViewType {
) : EmojiPickerView() {
override fun getViewType() = HOLDER_EMOJI_CATEGORY_HEADER
}
/**
* Emoji filter.
*/
object EmojiFilter : EmojiPickerView() {
override fun getViewType() = HOLDER_EMOJI_FILTER
}
}

View file

@ -2,20 +2,14 @@ package com.agileburo.anytype.library_page_icon_picker_widget.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.widget.doOnTextChanged
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.agileburo.anytype.library_page_icon_picker_widget.R
import com.agileburo.anytype.library_page_icon_picker_widget.model.EmojiPickerView
import com.agileburo.anytype.library_page_icon_picker_widget.model.PageIconPickerViewDiffUtil
import com.agileburo.anytype.library_page_icon_picker_widget.ui.DocumentEmojiIconPickerViewHolder.Companion.HOLDER_EMOJI_CATEGORY_HEADER
import com.agileburo.anytype.library_page_icon_picker_widget.ui.DocumentEmojiIconPickerViewHolder.Companion.HOLDER_EMOJI_FILTER
import com.agileburo.anytype.library_page_icon_picker_widget.ui.DocumentEmojiIconPickerViewHolder.Companion.HOLDER_EMOJI_ITEM
import kotlinx.android.synthetic.main.item_page_icon_picker_emoji_filter.view.*
class DocumentEmojiIconPickerAdapter(
private var views: List<EmojiPickerView>,
private val onFilterQueryChanged: (String) -> Unit,
private val onEmojiClicked: (String) -> Unit
) : RecyclerView.Adapter<DocumentEmojiIconPickerViewHolder>() {
@ -40,17 +34,6 @@ class DocumentEmojiIconPickerAdapter(
false
)
)
HOLDER_EMOJI_FILTER -> DocumentEmojiIconPickerViewHolder.EmojiFilter(
view = LayoutInflater.from(parent.context).inflate(
R.layout.item_page_icon_picker_emoji_filter,
parent,
false
)
).apply {
itemView.filterInputField.doOnTextChanged { text, _, _, _ ->
onFilterQueryChanged(text.toString())
}
}
else -> throw IllegalStateException("Unexpected view type: $viewType")
}
}
@ -73,13 +56,7 @@ class DocumentEmojiIconPickerAdapter(
}
fun update(update: List<EmojiPickerView>) {
val result = DiffUtil.calculateDiff(
PageIconPickerViewDiffUtil(
old = views,
new = update
)
)
views = update
result.dispatchUpdatesTo(this)
notifyDataSetChanged()
}
}

View file

@ -2,14 +2,15 @@ package com.agileburo.anytype.library_page_icon_picker_widget.ui
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.agileburo.anytype.emojifier.Emoji
import com.agileburo.anytype.emojifier.Emojifier
import com.agileburo.anytype.emojifier.data.Emoji
import com.agileburo.anytype.library_page_icon_picker_widget.R
import com.agileburo.anytype.library_page_icon_picker_widget.model.EmojiPickerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import kotlinx.android.synthetic.main.item_page_icon_picker_emoji_category_header.view.*
import kotlinx.android.synthetic.main.item_page_icon_picker_emoji_item.view.*
import timber.log.Timber
sealed class DocumentEmojiIconPickerViewHolder(view: View) : RecyclerView.ViewHolder(view) {
@ -27,9 +28,9 @@ sealed class DocumentEmojiIconPickerViewHolder(view: View) : RecyclerView.ViewHo
Emoji.CATEGORY_OBJECTS -> category.setText(R.string.category_objects)
Emoji.CATEGORY_SYMBOLS -> category.setText(R.string.category_symbols)
Emoji.CATEGORY_FLAGS -> category.setText(R.string.category_flags)
else -> Timber.d("Unexpected category: ${item.category}")
}
}
}
class EmojiItem(view: View) : DocumentEmojiIconPickerViewHolder(view) {
@ -50,11 +51,8 @@ sealed class DocumentEmojiIconPickerViewHolder(view: View) : RecyclerView.ViewHo
}
}
class EmojiFilter(view: View) : DocumentEmojiIconPickerViewHolder(view)
companion object {
const val HOLDER_EMOJI_CATEGORY_HEADER = 1
const val HOLDER_EMOJI_ITEM = 2
const val HOLDER_EMOJI_FILTER = 3
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ACA996"
android:fillType="evenOdd"
android:pathData="M22,12C22,17.5228 17.5228,22 12,22C6.4771,22 2,17.5228 2,12C2,6.4771 6.4771,2 12,2C17.5228,2 22,6.4771 22,12ZM16.5656,7.4343C16.8781,7.7467 16.8781,8.2532 16.5656,8.5656L13.1314,11.9999L16.5657,15.4343C16.8782,15.7467 16.8782,16.2532 16.5657,16.5656C16.2533,16.8781 15.7468,16.8781 15.4344,16.5656L12,13.1313L8.5656,16.5656C8.2532,16.8781 7.7467,16.8781 7.4343,16.5656C7.1219,16.2532 7.1219,15.7467 7.4343,15.4343L10.8686,11.9999L7.4344,8.5656C7.1219,8.2532 7.1219,7.7467 7.4344,7.4343C7.7468,7.1219 8.2533,7.1219 8.5657,7.4343L12,10.8685L15.4343,7.4343C15.7467,7.1219 16.2532,7.1219 16.5656,7.4343Z" />
</vector>

View file

@ -1,34 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:background="@drawable/page_icon_picker_filter_background"
android:orientation="horizontal">
android:background="@drawable/page_icon_picker_filter_background">
<ImageView
android:id="@+id/searchIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="12dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:background="@drawable/ic_page_icon_picker_search"
android:contentDescription="@string/content_description_loop_icon" />
android:contentDescription="@string/content_description_loop_icon"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:maxLines="1"
android:singleLine="true"
android:id="@+id/filterInputField"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="9dp"
android:layout_marginTop="7dp"
android:layout_marginEnd="9dp"
android:layout_marginBottom="7dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:background="@null"
android:fontFamily="@font/graphik_regular"
android:fontFamily="@font/inter_regular"
android:hint="@string/page_icon_picker_emoji_filter"
android:inputType="textNoSuggestions"
android:textSize="15sp" />
android:maxLines="1"
android:singleLine="true"
android:textSize="17sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/clearSearchText"
app:layout_constraintStart_toEndOf="@+id/searchIcon"
app:layout_constraintTop_toTopOf="parent" />
</LinearLayout>
<ImageView
android:id="@+id/clearSearchText"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_clear_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -7,29 +7,6 @@ import kotlin.test.assertEquals
class DocumentEmojiIconPickerViewDiffUtilTest {
@Test
fun `two emoji-filter items should be considered the same`() {
val old = listOf(
EmojiPickerView.EmojiFilter
)
val new = listOf(
EmojiPickerView.EmojiFilter
)
val util = PageIconPickerViewDiffUtil(
old = old,
new = new
)
val result = util.areItemsTheSame(0, 0)
assertEquals(
expected = true,
actual = result
)
}
@Test
fun `two emoji items should be considered the same`() {

View file

@ -4,61 +4,118 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.agileburo.anytype.domain.common.Id
import com.agileburo.anytype.domain.icon.SetDocumentEmojiIcon
import com.agileburo.anytype.emojifier.Emoji
import com.agileburo.anytype.emojifier.data.Emoji
import com.agileburo.anytype.emojifier.data.EmojiProvider
import com.agileburo.anytype.emojifier.suggest.EmojiSuggester
import com.agileburo.anytype.emojifier.suggest.model.EmojiSuggest
import com.agileburo.anytype.library_page_icon_picker_widget.model.EmojiPickerView
import com.agileburo.anytype.presentation.page.editor.Proxy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
class DocumentEmojiIconPickerViewModel(
private val setEmojiIcon: SetDocumentEmojiIcon
private val setEmojiIcon: SetDocumentEmojiIcon,
private val provider: EmojiProvider,
private val suggester: EmojiSuggester
) : ViewModel() {
/**
* Default emoji list, including categories.
*/
private val default = MutableStateFlow<List<EmojiPickerView>>(emptyList())
/**
* UI state stream.
*/
private val state: MutableStateFlow<ViewState> = MutableStateFlow(ViewState.Init)
/**
* Stream of user-generated queries.
*/
private val queries = Proxy.Subject<String>()
init {
viewModelScope.launch {
state.value = ViewState.Loading
state.value = ViewState.Success(views = load())
val loaded = loadEmojiWithCategories()
default.value = loaded
state.value = ViewState.Success(views = default.value)
}
viewModelScope.launch {
queries
.stream()
.debounce(DEBOUNCE_DURATION)
.distinctUntilChanged()
.onEach { state.value = ViewState.Loading }
.mapLatest { query ->
if (query.isEmpty())
default.value
else
select(suggester.search(query))
}
.flowOn(Dispatchers.Default)
.collect { state.value = ViewState.Success(it) }
}
}
fun state(): StateFlow<ViewState> = state
/**
* Maps found search suggests to emoji data, then adapts the latter to UI.
*/
private fun select(suggests: List<EmojiSuggest>): MutableList<EmojiPickerView.Emoji> {
val result = mutableListOf<EmojiPickerView.Emoji>()
suggests.forEach { suggest ->
provider.emojis.forEachIndexed loop@{ categoryIndex, emojis ->
emojis.forEachIndexed { emojiIndex, emoji ->
if (emoji == suggest.emoji) {
val skin = Emoji.COLORS.any { color -> emoji.contains(color) }
if (!skin) {
result.add(
EmojiPickerView.Emoji(
unicode = emoji,
index = emojiIndex,
page = categoryIndex
)
)
return@loop
}
}
}
}
}
return result
}
private suspend fun load(): List<EmojiPickerView> = withContext(Dispatchers.IO) {
private suspend fun loadEmojiWithCategories() = withContext(Dispatchers.IO) {
val views = mutableListOf<EmojiPickerView>()
views.add(EmojiPickerView.EmojiFilter)
Emoji.data.forEachIndexed { category, emojis ->
provider.emojis.forEachIndexed { categoryIndex, emojis ->
views.add(
EmojiPickerView.GroupHeader(
category = category
category = categoryIndex
)
)
emojis.forEachIndexed { index, unicode ->
val skin = Emoji.colors.any { color -> unicode.contains(color) }
if (!skin) {
emojis.forEachIndexed { emojiIndex, emoji ->
val skin = Emoji.COLORS.any { color -> emoji.contains(color) }
if (!skin)
views.add(
EmojiPickerView.Emoji(
unicode = unicode,
page = category,
index = index
unicode = emoji,
page = categoryIndex,
index = emojiIndex
)
)
}
}
}
views
}
fun state(): StateFlow<ViewState> = state
fun onEmojiClicked(unicode: String, target: Id, context: Id) {
viewModelScope.launch {
setEmojiIcon(
@ -74,10 +131,18 @@ class DocumentEmojiIconPickerViewModel(
}
}
fun onQueryChanged(query: String) {
viewModelScope.launch { queries.send(query) }
}
sealed class ViewState {
object Init : ViewState()
object Loading : ViewState()
data class Success(val views: List<EmojiPickerView>) : ViewState()
object Exit : ViewState()
}
companion object {
const val DEBOUNCE_DURATION = 300L
}
}

View file

@ -3,15 +3,21 @@ package com.agileburo.anytype.presentation.page.picker
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.agileburo.anytype.domain.icon.SetDocumentEmojiIcon
import com.agileburo.anytype.emojifier.data.EmojiProvider
import com.agileburo.anytype.emojifier.suggest.EmojiSuggester
class DocumentEmojiIconPickerViewModelFactory(
private val setEmojiIcon: SetDocumentEmojiIcon
private val setEmojiIcon: SetDocumentEmojiIcon,
private val emojiSuggester: EmojiSuggester,
private val emojiProvider: EmojiProvider
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return DocumentEmojiIconPickerViewModel(
setEmojiIcon = setEmojiIcon
setEmojiIcon = setEmojiIcon,
suggester = emojiSuggester,
provider = emojiProvider
) as T
}
}

View file

@ -4,7 +4,7 @@ import androidx.lifecycle.viewModelScope
import com.agileburo.anytype.core_utils.ui.ViewStateViewModel
import com.agileburo.anytype.domain.icon.SetDocumentEmojiIcon
import com.agileburo.anytype.domain.icon.SetDocumentImageIcon
import com.agileburo.anytype.emojifier.Emoji
import com.agileburo.anytype.emojifier.data.Emoji
import com.agileburo.anytype.presentation.common.StateReducer
import com.agileburo.anytype.presentation.page.picker.DocumentIconActionMenuViewModel.Contract.*
import com.agileburo.anytype.presentation.page.picker.DocumentIconActionMenuViewModel.ViewState
@ -71,7 +71,7 @@ class DocumentIconActionMenuViewModel(
success = { events.send(Event.OnCompleted) }
)
is Action.PickRandomEmoji -> {
val random = Emoji.data.random().random()
val random = Emoji.DATA.random().random()
events.send(
Event.OnRandomEmojiSelected(
target = action.target,