mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
Integrate page icon picker (#280)
This commit is contained in:
parent
b4a3401671
commit
ab1b602176
67 changed files with 1206 additions and 230 deletions
|
@ -82,6 +82,8 @@ dependencies {
|
|||
implementation project(':core-utils')
|
||||
implementation project(':core-ui')
|
||||
implementation project(':library-kanban-widget')
|
||||
implementation project(':library-page-icon-picker-widget')
|
||||
implementation project(':library-emojifier')
|
||||
|
||||
def applicationDependencies = rootProject.ext.mainApplication
|
||||
def unitTestDependencies = rootProject.ext.unitTesting
|
||||
|
|
|
@ -115,6 +115,13 @@ class ComponentManager(private val main: MainComponent) {
|
|||
.build()
|
||||
}
|
||||
|
||||
val pageIconPickerSubComponent = Component {
|
||||
main
|
||||
.pageIconPickerBuilder()
|
||||
.pageIconPickerModule(PageIconPickerModule())
|
||||
.build()
|
||||
}
|
||||
|
||||
class Component<T>(private val builder: () -> T) {
|
||||
|
||||
private var instance: T? = null
|
||||
|
|
|
@ -3,7 +3,7 @@ package com.agileburo.anytype.di.feature
|
|||
import com.agileburo.anytype.core_utils.di.scope.PerScreen
|
||||
import com.agileburo.anytype.domain.page.CheckForUnlink
|
||||
import com.agileburo.anytype.presentation.page.LinkAddViewModelFactory
|
||||
import com.agileburo.anytype.ui.page.modals.LinkFragment
|
||||
import com.agileburo.anytype.ui.page.modals.SetLinkFragment
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.Subcomponent
|
||||
|
@ -18,7 +18,7 @@ interface LinkSubComponent {
|
|||
fun build(): LinkSubComponent
|
||||
}
|
||||
|
||||
fun inject(fragment: LinkFragment)
|
||||
fun inject(fragment: SetLinkFragment)
|
||||
}
|
||||
|
||||
@Module
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.agileburo.anytype.domain.block.interactor.*
|
|||
import com.agileburo.anytype.domain.block.repo.BlockRepository
|
||||
import com.agileburo.anytype.domain.download.DownloadFile
|
||||
import com.agileburo.anytype.domain.download.Downloader
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.domain.event.interactor.EventChannel
|
||||
import com.agileburo.anytype.domain.event.interactor.InterceptEvents
|
||||
import com.agileburo.anytype.domain.misc.UrlBuilder
|
||||
|
@ -56,7 +57,8 @@ class PageModule {
|
|||
createPage: CreatePage,
|
||||
documentExternalEventReducer: DocumentExternalEventReducer,
|
||||
urlBuilder: UrlBuilder,
|
||||
downloadFile: DownloadFile
|
||||
downloadFile: DownloadFile,
|
||||
emojifier: Emojifier
|
||||
): PageViewModelFactory = PageViewModelFactory(
|
||||
openPage = openPage,
|
||||
closePage = closePage,
|
||||
|
@ -76,7 +78,8 @@ class PageModule {
|
|||
splitBlock = splitBlock,
|
||||
documentEventReducer = documentExternalEventReducer,
|
||||
urlBuilder = urlBuilder,
|
||||
downloadFile = downloadFile
|
||||
downloadFile = downloadFile,
|
||||
emojifier = emojifier
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
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.SetIconName
|
||||
import com.agileburo.anytype.presentation.page.picker.PageIconPickerViewModelFactory
|
||||
import com.agileburo.anytype.ui.page.modals.PageIconPickerFragment
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.Subcomponent
|
||||
|
||||
@Subcomponent(modules = [PageIconPickerModule::class])
|
||||
@PerScreen
|
||||
interface PageIconPickerSubComponent {
|
||||
|
||||
@Subcomponent.Builder
|
||||
interface Builder {
|
||||
fun pageIconPickerModule(module: PageIconPickerModule): Builder
|
||||
fun build(): PageIconPickerSubComponent
|
||||
}
|
||||
|
||||
fun inject(fragment: PageIconPickerFragment)
|
||||
}
|
||||
|
||||
@Module
|
||||
class PageIconPickerModule {
|
||||
|
||||
@Provides
|
||||
@PerScreen
|
||||
fun providePageIconPickerViewModelFactory(
|
||||
setIconName: SetIconName
|
||||
): PageIconPickerViewModelFactory = PageIconPickerViewModelFactory(
|
||||
setIconName = setIconName
|
||||
)
|
||||
|
||||
@Provides
|
||||
@PerScreen
|
||||
fun provideSetIconNameUseCase(
|
||||
repo: BlockRepository
|
||||
): SetIconName = SetIconName(
|
||||
repo = repo
|
||||
)
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package com.agileburo.anytype.di.main
|
||||
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.emojifier.DefaultEmojifier
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
class EmojiModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideEmojifier(): Emojifier = DefaultEmojifier()
|
||||
}
|
|
@ -13,7 +13,8 @@ import javax.inject.Singleton
|
|||
ImageModule::class,
|
||||
ConfigModule::class,
|
||||
DeviceModule::class,
|
||||
UtilModule::class
|
||||
UtilModule::class,
|
||||
EmojiModule::class
|
||||
]
|
||||
)
|
||||
interface MainComponent {
|
||||
|
@ -32,4 +33,5 @@ interface MainComponent {
|
|||
fun detailsReorderBuilder(): DetailsReorderSubComponent.Builder
|
||||
fun pageComponentBuilder(): PageSubComponent.Builder
|
||||
fun linkAddComponentBuilder(): LinkSubComponent.Builder
|
||||
fun pageIconPickerBuilder(): PageIconPickerSubComponent.Builder
|
||||
}
|
|
@ -19,6 +19,8 @@ class DashboardAdapter(
|
|||
|
||||
companion object {
|
||||
const val VIEW_TYPE_DOCUMENT = 0
|
||||
const val UNEXPECTED_TYPE_ERROR_MESSAGE = "Unexpected type"
|
||||
const val EMPTY_EMOJI = ""
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
|
@ -36,7 +38,7 @@ class DashboardAdapter(
|
|||
override fun getItemViewType(position: Int): Int {
|
||||
return when (data[position]) {
|
||||
is DashboardView.Document -> VIEW_TYPE_DOCUMENT
|
||||
else -> throw IllegalStateException("Unexpected type")
|
||||
else -> throw IllegalStateException(UNEXPECTED_TYPE_ERROR_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,7 +62,7 @@ class DashboardAdapter(
|
|||
fun bind(doc: DashboardView.Document, onClick: (DashboardView.Document) -> Unit) {
|
||||
itemView.setOnClickListener { onClick(doc) }
|
||||
itemView.title.text = doc.title
|
||||
itemView.emoji.text = if (doc.emoji.isNotEmpty()) doc.emoji else "🎬"
|
||||
itemView.emoji.text = doc.emoji ?: EMPTY_EMOJI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.os.Bundle
|
|||
import android.view.View
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import com.agileburo.anytype.R
|
||||
|
@ -15,6 +16,7 @@ import com.agileburo.anytype.core_utils.ext.visible
|
|||
import com.agileburo.anytype.core_utils.ui.EqualSpacingItemDecoration
|
||||
import com.agileburo.anytype.core_utils.ui.EqualSpacingItemDecoration.Companion.GRID
|
||||
import com.agileburo.anytype.di.common.componentManager
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.presentation.desktop.HomeDashboardStateMachine.State
|
||||
import com.agileburo.anytype.presentation.desktop.HomeDashboardViewModel
|
||||
import com.agileburo.anytype.presentation.desktop.HomeDashboardViewModelFactory
|
||||
|
@ -22,6 +24,9 @@ import com.agileburo.anytype.presentation.mapper.toView
|
|||
import com.agileburo.anytype.presentation.profile.ProfileView
|
||||
import com.agileburo.anytype.ui.base.ViewStateFragment
|
||||
import kotlinx.android.synthetic.main.fragment_desktop.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -62,6 +67,9 @@ class HomeDashboardFragment : ViewStateFragment<State>(R.layout.fragment_desktop
|
|||
@Inject
|
||||
lateinit var factory: HomeDashboardViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var emojifier: Emojifier
|
||||
|
||||
private val dashboardAdapter by lazy {
|
||||
DashboardAdapter(
|
||||
data = mutableListOf(),
|
||||
|
@ -97,7 +105,13 @@ class HomeDashboardFragment : ViewStateFragment<State>(R.layout.fragment_desktop
|
|||
state.dashboard != null -> {
|
||||
progress.invisible()
|
||||
fab.visible()
|
||||
state.dashboard?.let { dashboardAdapter.update(it.toView()) }
|
||||
state.dashboard?.let { dashboard ->
|
||||
lifecycleScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) { dashboard.toView(emojifier = emojifier) }
|
||||
dashboardAdapter.update(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ import com.agileburo.anytype.core_ui.widgets.toolbar.OptionToolbarWidget.OptionC
|
|||
import com.agileburo.anytype.core_ui.widgets.toolbar.OptionToolbarWidget.OptionConfig.OPTION_TEXT_HIGHLIGHTED
|
||||
import com.agileburo.anytype.core_ui.widgets.toolbar.OptionToolbarWidget.OptionConfig.OPTION_TEXT_TEXT
|
||||
import com.agileburo.anytype.core_ui.widgets.toolbar.OptionToolbarWidget.OptionConfig.OPTION_TOOL_PAGE
|
||||
import com.agileburo.anytype.core_utils.common.EventWrapper
|
||||
import com.agileburo.anytype.core_utils.ext.gone
|
||||
import com.agileburo.anytype.core_utils.ext.hexColorCode
|
||||
import com.agileburo.anytype.core_utils.ext.hideSoftInput
|
||||
|
@ -44,7 +45,8 @@ import com.agileburo.anytype.ext.extractMarks
|
|||
import com.agileburo.anytype.presentation.page.PageViewModel
|
||||
import com.agileburo.anytype.presentation.page.PageViewModelFactory
|
||||
import com.agileburo.anytype.ui.base.NavigationFragment
|
||||
import com.agileburo.anytype.ui.page.modals.LinkFragment
|
||||
import com.agileburo.anytype.ui.page.modals.PageIconPickerFragment
|
||||
import com.agileburo.anytype.ui.page.modals.SetLinkFragment
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import kotlinx.android.synthetic.main.fragment_page.*
|
||||
import kotlinx.coroutines.delay
|
||||
|
@ -90,7 +92,8 @@ open class PageFragment : NavigationFragment(R.layout.fragment_page),
|
|||
onFooterClicked = vm::onOutsideClicked,
|
||||
onPageClicked = vm::onPageClicked,
|
||||
onTextInputClicked = vm::onTextInputClicked,
|
||||
onDownloadFileClicked = vm::onDownloadFileClicked
|
||||
onDownloadFileClicked = vm::onDownloadFileClicked,
|
||||
onPageIconClicked = vm::onPageIconClicked
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -329,6 +332,7 @@ open class PageFragment : NavigationFragment(R.layout.fragment_page),
|
|||
vm.navigation.observe(viewLifecycleOwner, navObserver)
|
||||
vm.controlPanelViewState.observe(viewLifecycleOwner, Observer { render(it) })
|
||||
vm.focus.observe(viewLifecycleOwner, Observer { handleFocus(it) })
|
||||
vm.commands.observe(viewLifecycleOwner, Observer { execute(it) })
|
||||
}
|
||||
|
||||
private fun handleFocus(focus: Id) {
|
||||
|
@ -342,13 +346,26 @@ open class PageFragment : NavigationFragment(R.layout.fragment_page),
|
|||
}
|
||||
}
|
||||
|
||||
private fun execute(event: EventWrapper<PageViewModel.Command>) {
|
||||
event.getContentIfNotHandled()?.let { command ->
|
||||
when (command) {
|
||||
is PageViewModel.Command.OpenPagePicker -> {
|
||||
PageIconPickerFragment.newInstance(
|
||||
context = requireArguments().getString(ID_KEY, ID_EMPTY_VALUE),
|
||||
target = command.target
|
||||
).show(childFragmentManager, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(state: PageViewModel.ViewState) {
|
||||
when (state) {
|
||||
is PageViewModel.ViewState.Success -> {
|
||||
pageAdapter.updateWithDiffUtil(state.blocks)
|
||||
}
|
||||
is PageViewModel.ViewState.OpenLinkScreen -> {
|
||||
LinkFragment.newInstance(
|
||||
SetLinkFragment.newInstance(
|
||||
blockId = state.block.id,
|
||||
initUrl = state.block.getFirstLinkMarkupParam(state.range),
|
||||
text = state.block.getSubstring(state.range),
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
package com.agileburo.anytype.ui.page.modals
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.agileburo.anytype.R
|
||||
import com.agileburo.anytype.core_utils.ext.toast
|
||||
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.PageIconPickerAdapter
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.ui.PageIconPickerViewHolder
|
||||
import com.agileburo.anytype.presentation.page.picker.PageIconPickerViewModel
|
||||
import com.agileburo.anytype.presentation.page.picker.PageIconPickerViewModel.Contract
|
||||
import com.agileburo.anytype.presentation.page.picker.PageIconPickerViewModel.ViewState
|
||||
import com.agileburo.anytype.presentation.page.picker.PageIconPickerViewModelFactory
|
||||
import kotlinx.android.synthetic.main.fragment_page_icon_picker.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class PageIconPickerFragment : BaseBottomSheetFragment() {
|
||||
|
||||
private val target: String
|
||||
get() = requireArguments()
|
||||
.getString(ARG_TARGET_ID_KEY)
|
||||
?: throw IllegalStateException(MISSING_TARGET_ERROR)
|
||||
|
||||
private val context: String
|
||||
get() = requireArguments()
|
||||
.getString(ARG_CONTEXT_ID_KEY)
|
||||
?: throw IllegalStateException(MISSING_CONTEXT_ERROR)
|
||||
|
||||
@Inject
|
||||
lateinit var factory: PageIconPickerViewModelFactory
|
||||
|
||||
private val vm by lazy {
|
||||
ViewModelProviders
|
||||
.of(this, factory)
|
||||
.get(PageIconPickerViewModel::class.java)
|
||||
}
|
||||
|
||||
private val pageIconPickerAdapter by lazy {
|
||||
PageIconPickerAdapter(
|
||||
views = emptyList(),
|
||||
onUploadPhotoClicked = { toast(NOT_IMPLEMENTED_MESSAGE) },
|
||||
onFilterQueryChanged = { vm.onEvent(Contract.Event.OnFilterQueryChanged(it)) },
|
||||
onSetRandomEmojiClicked = {
|
||||
vm.onEvent(
|
||||
Contract.Event.OnSetRandomEmojiClicked(
|
||||
target = target,
|
||||
context = context
|
||||
)
|
||||
)
|
||||
},
|
||||
onEmojiClicked = { unicode, alias ->
|
||||
vm.onEvent(
|
||||
Contract.Event.OnEmojiClicked(
|
||||
unicode = unicode,
|
||||
alias = alias,
|
||||
target = target,
|
||||
context = context
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(DialogFragment.STYLE_NORMAL, R.style.DialogStyle)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? = inflater.inflate(R.layout.fragment_page_icon_picker, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
recyler.apply {
|
||||
setItemViewCacheSize(EMOJI_RECYCLER_ITEM_VIEW_CACHE_SIZE)
|
||||
setHasFixedSize(true)
|
||||
layoutManager = GridLayoutManager(context, PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT).apply {
|
||||
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int) =
|
||||
when (val type = pageIconPickerAdapter.getItemViewType(position)) {
|
||||
PageIconPickerViewHolder.HOLDER_UPLOAD_PHOTO -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_CHOOSE_EMOJI -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_PICK_RANDOM_EMOJI -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_EMOJI_CATEGORY_HEADER -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_EMOJI_FILTER -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_EMOJI_ITEM -> 1
|
||||
else -> throw IllegalStateException("$UNEXPECTED_VIEW_TYPE_MESSAGE: $type")
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter = pageIconPickerAdapter.apply {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
}
|
||||
|
||||
remove.setOnClickListener {
|
||||
vm.onEvent(
|
||||
Contract.Event.OnRemoveEmojiSelected(
|
||||
context = context,
|
||||
target = target
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
vm.state.observe(viewLifecycleOwner, Observer { render(it) })
|
||||
}
|
||||
|
||||
fun render(state: ViewState) {
|
||||
when (state) {
|
||||
is ViewState.Success -> pageIconPickerAdapter.update(state.views)
|
||||
is ViewState.Exit -> dismiss()
|
||||
is ViewState.Error -> toast(state.message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun injectDependencies() {
|
||||
componentManager().pageIconPickerSubComponent.get().inject(this)
|
||||
}
|
||||
|
||||
override fun releaseDependencies() {
|
||||
componentManager().pageIconPickerSubComponent.release()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance(
|
||||
context: String,
|
||||
target: String
|
||||
) = PageIconPickerFragment().apply {
|
||||
arguments = bundleOf(ARG_CONTEXT_ID_KEY to context, ARG_TARGET_ID_KEY to target)
|
||||
}
|
||||
|
||||
private const val PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT = 8
|
||||
private const val EMOJI_RECYCLER_ITEM_VIEW_CACHE_SIZE = 100
|
||||
private const val ARG_CONTEXT_ID_KEY = "arg.picker.context.id"
|
||||
private const val ARG_TARGET_ID_KEY = "arg.picker.target.id"
|
||||
private const val MISSING_TARGET_ERROR = "Missing target id"
|
||||
private const val MISSING_CONTEXT_ERROR = "Missing context id"
|
||||
private const val NOT_IMPLEMENTED_MESSAGE = "Not implemented"
|
||||
private const val UNEXPECTED_VIEW_TYPE_MESSAGE = "Unexpected view type"
|
||||
}
|
||||
}
|
|
@ -18,7 +18,7 @@ import com.agileburo.anytype.ui.page.OnFragmentInteractionListener
|
|||
import kotlinx.android.synthetic.main.fragment_link.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class LinkFragment : BaseBottomSheetFragment() {
|
||||
class SetLinkFragment : BaseBottomSheetFragment() {
|
||||
|
||||
companion object {
|
||||
const val ARG_URL = "arg.link.url"
|
||||
|
@ -34,7 +34,7 @@ class LinkFragment : BaseBottomSheetFragment() {
|
|||
rangeEnd: Int,
|
||||
blockId: String
|
||||
) =
|
||||
LinkFragment().apply {
|
||||
SetLinkFragment().apply {
|
||||
arguments = bundleOf(
|
||||
ARG_TEXT to text,
|
||||
ARG_URL to initUrl,
|
54
app/src/main/res/layout/fragment_page_icon_picker.xml
Normal file
54
app/src/main/res/layout/fragment_page_icon_picker.xml
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/sheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/rectangle_debug_background"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/dragger"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="6dp"
|
||||
android:background="@drawable/page_icon_picker_dragger_background" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="30dp"
|
||||
android:fontFamily="@font/graphik_semibold"
|
||||
android:text="@string/page_icon"
|
||||
android:textColor="@color/black"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/remove"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="30dp"
|
||||
android:text="@string/page_icon_picker_remove_text"
|
||||
android:textColor="#ACA996"
|
||||
android:textSize="17sp" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:minHeight="300dp" />
|
||||
|
||||
</LinearLayout>
|
|
@ -91,4 +91,7 @@ Do the computation of an expensive paragraph of text on a background thread:
|
|||
<string name="button_unlink">Unlink</string>
|
||||
<string name="button_link">Link</string>
|
||||
|
||||
<string name="page_icon">Page icon</string>
|
||||
<string name="page_icon_picker_remove_text">Remove</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -48,6 +48,7 @@ class BlockAdapter(
|
|||
private val onFooterClicked: () -> Unit,
|
||||
private val onPageClicked: (String) -> Unit,
|
||||
private val onTextInputClicked: () -> Unit,
|
||||
private val onPageIconClicked: () -> Unit,
|
||||
private val onDownloadFileClicked: (String) -> Unit
|
||||
) : RecyclerView.Adapter<BlockViewHolder>() {
|
||||
|
||||
|
@ -310,7 +311,8 @@ class BlockAdapter(
|
|||
holder.bind(
|
||||
item = blocks[position] as BlockView.Title,
|
||||
onTextChanged = onTextChanged,
|
||||
onFocusChanged = onFocusChanged
|
||||
onFocusChanged = onFocusChanged,
|
||||
onPageIconClicked = onPageIconClicked
|
||||
)
|
||||
}
|
||||
is BlockViewHolder.HeaderOne -> {
|
||||
|
|
|
@ -239,6 +239,8 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
|||
|
||||
class Title(view: View) : BlockViewHolder(view), TextHolder {
|
||||
|
||||
private val icon = itemView.logo
|
||||
|
||||
override val root: View = itemView
|
||||
override val content: TextInputWidget = itemView.title
|
||||
|
||||
|
@ -249,7 +251,8 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
|||
fun bind(
|
||||
item: BlockView.Title,
|
||||
onTextChanged: (String, Editable) -> Unit,
|
||||
onFocusChanged: (String, Boolean) -> Unit
|
||||
onFocusChanged: (String, Boolean) -> Unit,
|
||||
onPageIconClicked: () -> Unit
|
||||
) {
|
||||
content.clearTextWatchers()
|
||||
content.setOnFocusChangeListener { _, hasFocus ->
|
||||
|
@ -257,6 +260,8 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
|||
}
|
||||
content.setText(item.text, BufferType.EDITABLE)
|
||||
setupTextWatcher(onTextChanged, item)
|
||||
icon.text = item.emoji ?: EMPTY_EMOJI
|
||||
icon.setOnClickListener { onPageIconClicked() }
|
||||
}
|
||||
|
||||
fun processPayloads(
|
||||
|
@ -279,6 +284,10 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
|||
onEmptyBlockBackspaceClicked: () -> Unit,
|
||||
onNonEmptyBlockBackspaceClicked: () -> Unit
|
||||
) = Unit
|
||||
|
||||
companion object {
|
||||
private const val EMPTY_EMOJI = ""
|
||||
}
|
||||
}
|
||||
|
||||
class HeaderOne(view: View) : BlockViewHolder(view), TextHolder {
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
android:background="@drawable/rectangle_default_page_logo_background"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="45dp"
|
||||
android:text="✍️"
|
||||
android:textColor="@color/emoji_color"
|
||||
android:textSize="28sp" />
|
||||
|
||||
|
|
|
@ -847,7 +847,8 @@ class BlockAdapterTest {
|
|||
onFooterClicked = {},
|
||||
onPageClicked = {},
|
||||
onTextInputClicked = {},
|
||||
onDownloadFileClicked = {}
|
||||
onDownloadFileClicked = {},
|
||||
onPageIconClicked = {}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -82,6 +82,7 @@ fun BlockEntity.Content.toDomain(): Block.Content = when (this) {
|
|||
is BlockEntity.Content.Link -> toDomain()
|
||||
is BlockEntity.Content.Divider -> toDomain()
|
||||
is BlockEntity.Content.File -> toDomain()
|
||||
is BlockEntity.Content.Icon -> toDomain()
|
||||
}
|
||||
|
||||
fun BlockEntity.Content.File.toDomain(): Block.Content.File {
|
||||
|
@ -96,6 +97,10 @@ fun BlockEntity.Content.File.toDomain(): Block.Content.File {
|
|||
)
|
||||
}
|
||||
|
||||
fun BlockEntity.Content.Icon.toDomain(): Block.Content.Icon = Block.Content.Icon(
|
||||
name = name
|
||||
)
|
||||
|
||||
fun BlockEntity.Content.File.Type.toDomain(): Block.Content.File.Type {
|
||||
return when (this) {
|
||||
BlockEntity.Content.File.Type.NONE -> Block.Content.File.Type.NONE
|
||||
|
@ -197,6 +202,7 @@ fun Block.Content.toEntity(): BlockEntity.Content = when (this) {
|
|||
is Block.Content.Link -> toEntity()
|
||||
is Block.Content.Divider -> toEntity()
|
||||
is Block.Content.File -> toEntity()
|
||||
is Block.Content.Icon -> toEntity()
|
||||
}
|
||||
|
||||
fun Block.Content.File.toEntity(): BlockEntity.Content.File {
|
||||
|
@ -211,6 +217,10 @@ fun Block.Content.File.toEntity(): BlockEntity.Content.File {
|
|||
)
|
||||
}
|
||||
|
||||
fun Block.Content.Icon.toEntity(): BlockEntity.Content.Icon = BlockEntity.Content.Icon(
|
||||
name = name
|
||||
)
|
||||
|
||||
fun Block.Content.File.Type.toEntity(): BlockEntity.Content.File.Type {
|
||||
return when (this) {
|
||||
Block.Content.File.Type.NONE -> BlockEntity.Content.File.Type.NONE
|
||||
|
@ -349,6 +359,12 @@ fun Command.Split.toEntity(): CommandEntity.Split = CommandEntity.Split(
|
|||
index = index
|
||||
)
|
||||
|
||||
fun Command.SetIconName.toEntity() = CommandEntity.SetIconName(
|
||||
target = target,
|
||||
context = context,
|
||||
name = name
|
||||
)
|
||||
|
||||
fun Position.toEntity(): PositionEntity {
|
||||
return PositionEntity.valueOf(name)
|
||||
}
|
||||
|
@ -410,6 +426,13 @@ fun EventEntity.toDomain(): Event {
|
|||
fields = fields?.let { Block.Fields(it.map) }
|
||||
)
|
||||
}
|
||||
is EventEntity.Command.UpdateFields -> {
|
||||
Event.Command.UpdateFields(
|
||||
context = context,
|
||||
target = target,
|
||||
fields = Block.Fields(fields.map)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ data class BlockEntity(
|
|||
val content: Content,
|
||||
val fields: Fields
|
||||
) {
|
||||
data class Fields(val map: MutableMap<String, Any?> = mutableMapOf())
|
||||
data class Fields(val map: MutableMap<String?, Any?> = mutableMapOf())
|
||||
|
||||
sealed class Content {
|
||||
|
||||
|
@ -52,6 +52,10 @@ data class BlockEntity(
|
|||
val path: String
|
||||
) : Content()
|
||||
|
||||
data class Icon(
|
||||
val name: String
|
||||
) : Content()
|
||||
|
||||
data class Dashboard(val type: Type) : Content() {
|
||||
enum class Type { MAIN_SCREEN, ARCHIVE }
|
||||
}
|
||||
|
|
|
@ -71,4 +71,10 @@ class CommandEntity {
|
|||
val target: String,
|
||||
val index: Int
|
||||
)
|
||||
|
||||
data class SetIconName(
|
||||
val context: String,
|
||||
val target: String,
|
||||
val name: String
|
||||
)
|
||||
}
|
|
@ -50,5 +50,11 @@ sealed class EventEntity {
|
|||
override val context: String,
|
||||
val targets: List<String>
|
||||
) : Command()
|
||||
|
||||
data class UpdateFields(
|
||||
override val context: String,
|
||||
val target: String,
|
||||
val fields: BlockEntity.Fields
|
||||
) : Command()
|
||||
}
|
||||
}
|
|
@ -67,4 +67,7 @@ class BlockDataRepository(
|
|||
}
|
||||
|
||||
override suspend fun split(command: Command.Split) = factory.remote.split(command.toEntity())
|
||||
|
||||
override suspend fun setIconName(command: Command.SetIconName) =
|
||||
factory.remote.setIconName(command.toEntity())
|
||||
}
|
|
@ -22,4 +22,5 @@ interface BlockDataStore {
|
|||
suspend fun closePage(id: String)
|
||||
suspend fun openDashboard(contextId: String, id: String)
|
||||
suspend fun closeDashboard(id: String)
|
||||
suspend fun setIconName(command: CommandEntity.SetIconName)
|
||||
}
|
|
@ -22,4 +22,5 @@ interface BlockRemote {
|
|||
suspend fun closePage(id: String)
|
||||
suspend fun openDashboard(contextId: String, id: String)
|
||||
suspend fun closeDashboard(id: String)
|
||||
suspend fun setIconName(command: CommandEntity.SetIconName)
|
||||
}
|
|
@ -60,4 +60,7 @@ class BlockRemoteDataStore(private val remote: BlockRemote) : BlockDataStore {
|
|||
}
|
||||
|
||||
override suspend fun split(command: CommandEntity.Split): String = remote.split(command)
|
||||
|
||||
override suspend fun setIconName(command: CommandEntity.SetIconName) =
|
||||
remote.setIconName(command)
|
||||
}
|
|
@ -22,16 +22,15 @@ data class Block(
|
|||
* Block fields containing useful block properties.
|
||||
* @property map map containing fields
|
||||
*/
|
||||
data class Fields(val map: Map<String, Any?>) {
|
||||
data class Fields(val map: Map<String?, Any?>) {
|
||||
|
||||
val name: String by map
|
||||
val icon: String by map
|
||||
val icon: String? by map
|
||||
|
||||
fun hasName() = map.containsKey(NAME_KEY)
|
||||
|
||||
companion object {
|
||||
fun empty(): Fields = Fields(emptyMap())
|
||||
|
||||
const val NAME_KEY = "name"
|
||||
}
|
||||
}
|
||||
|
@ -140,6 +139,14 @@ data class Block(
|
|||
enum class Type { PAGE, DATA_VIEW, DASHBOARD, ARCHIVE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Page icon.
|
||||
* @property name conventional emoji short name.
|
||||
*/
|
||||
data class Icon(
|
||||
val name: String
|
||||
) : Content()
|
||||
|
||||
/**
|
||||
* File block.
|
||||
* @property hash file hash
|
||||
|
|
|
@ -127,4 +127,10 @@ sealed class Command {
|
|||
val target: Id,
|
||||
val index: Int
|
||||
)
|
||||
|
||||
data class SetIconName(
|
||||
val context: Id,
|
||||
val target: Id,
|
||||
val name: String
|
||||
)
|
||||
}
|
|
@ -34,4 +34,6 @@ interface BlockRepository {
|
|||
suspend fun closePage(id: String)
|
||||
suspend fun openDashboard(contextId: String, id: String)
|
||||
suspend fun closeDashboard(id: String)
|
||||
|
||||
suspend fun setIconName(command: Command.SetIconName)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.agileburo.anytype.domain.emoji
|
||||
|
||||
data class Emoji(
|
||||
val unicode: String,
|
||||
val alias: String
|
||||
) {
|
||||
val name: String
|
||||
get() = ":$alias:"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package com.agileburo.anytype.domain.emoji
|
||||
|
||||
interface Emojifier {
|
||||
suspend fun fromAlias(alias: String): Emoji
|
||||
suspend fun fromShortName(name: String): Emoji
|
||||
}
|
|
@ -80,5 +80,11 @@ sealed class Event {
|
|||
val id: Id,
|
||||
val children: List<Id>
|
||||
) : Command()
|
||||
|
||||
data class UpdateFields(
|
||||
override val context: Id,
|
||||
val target: Id,
|
||||
val fields: Block.Fields
|
||||
) : Command()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package com.agileburo.anytype.domain.icon
|
||||
|
||||
import com.agileburo.anytype.domain.base.BaseUseCase
|
||||
import com.agileburo.anytype.domain.base.Either
|
||||
import com.agileburo.anytype.domain.block.model.Command
|
||||
import com.agileburo.anytype.domain.block.repo.BlockRepository
|
||||
import com.agileburo.anytype.domain.common.Id
|
||||
|
||||
/**
|
||||
* Use-case for setting emoji icon's short name
|
||||
*/
|
||||
class SetIconName(private val repo: BlockRepository) : BaseUseCase<Any, SetIconName.Params>() {
|
||||
|
||||
override suspend fun run(params: Params) = try {
|
||||
repo.setIconName(
|
||||
command = Command.SetIconName(
|
||||
context = params.context,
|
||||
target = params.target,
|
||||
name = params.name
|
||||
)
|
||||
).let {
|
||||
Either.Right(it)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Either.Left(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Params for setting icon name
|
||||
* @property name emoji's short-name code
|
||||
* @property target id of the target block (icon)
|
||||
* @property context id of the context for this operation
|
||||
*/
|
||||
data class Params(
|
||||
val name: String,
|
||||
val target: Id,
|
||||
val context: Id
|
||||
)
|
||||
}
|
1
library-emojifier/.gitignore
vendored
Normal file
1
library-emojifier/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
46
library-emojifier/build.gradle
Normal file
46
library-emojifier/build.gradle
Normal file
|
@ -0,0 +1,46 @@
|
|||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
android {
|
||||
def config = rootProject.extensions.getByName("ext")
|
||||
|
||||
compileSdkVersion config["compile_sdk"]
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion config["min_sdk"]
|
||||
targetSdkVersion config["target_sdk"]
|
||||
versionCode config["version_code"]
|
||||
versionName config["version_name"]
|
||||
|
||||
testInstrumentationRunner config["test_runner"]
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':domain')
|
||||
def lib = rootProject.ext.libraryPageIconPicker
|
||||
implementation lib.emojiJava
|
||||
}
|
0
library-emojifier/consumer-rules.pro
Normal file
0
library-emojifier/consumer-rules.pro
Normal file
21
library-emojifier/proguard-rules.pro
vendored
Normal file
21
library-emojifier/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
1
library-emojifier/src/main/AndroidManifest.xml
Normal file
1
library-emojifier/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1 @@
|
|||
<manifest package="com.agileburo.anytype.emojifier" />
|
|
@ -0,0 +1,24 @@
|
|||
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(
|
||||
unicode = result.unicode,
|
||||
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)
|
||||
}
|
||||
}
|
3
library-emojifier/src/main/res/values/strings.xml
Normal file
3
library-emojifier/src/main/res/values/strings.xml
Normal file
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Library Emojifier</string>
|
||||
</resources>
|
|
@ -14,7 +14,7 @@ sealed class PageIconPickerView : ViewType {
|
|||
* @property alias short name or convenient name for an emoji.
|
||||
*/
|
||||
data class Emoji(
|
||||
val alias: String = "",
|
||||
val alias: String,
|
||||
val unicode: String
|
||||
) : PageIconPickerView() {
|
||||
override fun getViewType() = HOLDER_EMOJI_ITEM
|
||||
|
|
|
@ -3,9 +3,11 @@ 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.R
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.model.PageIconPickerView
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.model.PageIconPickerViewDiffUtil
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.ui.PageIconPickerViewHolder.Companion.HOLDER_CHOOSE_EMOJI
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.ui.PageIconPickerViewHolder.Companion.HOLDER_EMOJI_CATEGORY_HEADER
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.ui.PageIconPickerViewHolder.Companion.HOLDER_EMOJI_FILTER
|
||||
|
@ -18,7 +20,8 @@ class PageIconPickerAdapter(
|
|||
private var views: List<PageIconPickerView>,
|
||||
private val onUploadPhotoClicked: () -> Unit,
|
||||
private val onSetRandomEmojiClicked: () -> Unit,
|
||||
private val onFilterQueryChanged: (String) -> Unit
|
||||
private val onFilterQueryChanged: (String) -> Unit,
|
||||
private val onEmojiClicked: (String, String) -> Unit
|
||||
) : RecyclerView.Adapter<PageIconPickerViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageIconPickerViewHolder {
|
||||
|
@ -88,8 +91,22 @@ class PageIconPickerAdapter(
|
|||
holder.bind(views[position] as PageIconPickerView.GroupHeader)
|
||||
}
|
||||
is PageIconPickerViewHolder.EmojiItem -> {
|
||||
holder.bind(views[position] as PageIconPickerView.Emoji)
|
||||
holder.bind(
|
||||
item = views[position] as PageIconPickerView.Emoji,
|
||||
onEmojiClicked = onEmojiClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(update: List<PageIconPickerView>) {
|
||||
val result = DiffUtil.calculateDiff(
|
||||
PageIconPickerViewDiffUtil(
|
||||
old = views,
|
||||
new = update
|
||||
)
|
||||
)
|
||||
views = update
|
||||
result.dispatchUpdatesTo(this)
|
||||
}
|
||||
}
|
|
@ -29,9 +29,11 @@ sealed class PageIconPickerViewHolder(view: View) : RecyclerView.ViewHolder(view
|
|||
private val emoji = itemView.emoji
|
||||
|
||||
fun bind(
|
||||
item: PageIconPickerView.Emoji
|
||||
item: PageIconPickerView.Emoji,
|
||||
onEmojiClicked: (String, String) -> Unit
|
||||
) {
|
||||
emoji.text = item.unicode
|
||||
itemView.setOnClickListener { onEmojiClicked(item.unicode, item.alias) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -216,6 +216,10 @@ fun Block.link(): BlockEntity.Content.Link = BlockEntity.Content.Link(
|
|||
|
||||
fun Block.divider(): BlockEntity.Content.Divider = BlockEntity.Content.Divider
|
||||
|
||||
fun Block.icon(): BlockEntity.Content.Icon = BlockEntity.Content.Icon(
|
||||
name = icon.name
|
||||
)
|
||||
|
||||
fun Block.file(): BlockEntity.Content.File = BlockEntity.Content.File(
|
||||
hash = file.hash,
|
||||
name = file.name,
|
||||
|
@ -296,6 +300,14 @@ fun List<Block>.blocks(): List<BlockEntity> = mapNotNull { block ->
|
|||
content = block.file()
|
||||
)
|
||||
}
|
||||
Block.ContentCase.ICON -> {
|
||||
BlockEntity(
|
||||
id = block.id,
|
||||
children = block.childrenIdsList,
|
||||
fields = block.fields(),
|
||||
content = block.icon()
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
|
|
|
@ -82,4 +82,7 @@ class BlockMiddleware(
|
|||
}
|
||||
|
||||
override suspend fun split(command: CommandEntity.Split): String = middleware.split(command)
|
||||
|
||||
override suspend fun setIconName(command: CommandEntity.SetIconName) =
|
||||
middleware.setIconName(command)
|
||||
}
|
|
@ -544,4 +544,17 @@ public class Middleware {
|
|||
|
||||
return response.getBlockId();
|
||||
}
|
||||
|
||||
public void setIconName(CommandEntity.SetIconName command) throws Exception {
|
||||
Block.Set.Icon.Name.Request request = Block.Set.Icon.Name.Request
|
||||
.newBuilder()
|
||||
.setBlockId(command.getTarget())
|
||||
.setContextId(command.getContext())
|
||||
.setName(command.getName())
|
||||
.build();
|
||||
|
||||
Timber.d("Setting icon name with the following request:\n%s", request.toString());
|
||||
|
||||
service.blockSetIconName(request);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,8 @@ class MiddlewareEventChannel(
|
|||
Events.Event.Message.ValueCase.BLOCKSETTEXT,
|
||||
Events.Event.Message.ValueCase.BLOCKSETCHILDRENIDS,
|
||||
Events.Event.Message.ValueCase.BLOCKDELETE,
|
||||
Events.Event.Message.ValueCase.BLOCKSETLINK
|
||||
Events.Event.Message.ValueCase.BLOCKSETLINK,
|
||||
Events.Event.Message.ValueCase.BLOCKSETFIELDS
|
||||
)
|
||||
|
||||
override fun observeEvents(
|
||||
|
@ -103,6 +104,13 @@ class MiddlewareEventChannel(
|
|||
null
|
||||
)
|
||||
}
|
||||
Events.Event.Message.ValueCase.BLOCKSETFIELDS -> {
|
||||
EventEntity.Command.UpdateFields(
|
||||
context = context,
|
||||
target = event.blockSetFields.id,
|
||||
fields = event.blockSetFields.fields.fields()
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -251,4 +251,15 @@ public class DefaultMiddlewareService implements MiddlewareService {
|
|||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Block.Set.Icon.Name.Response blockSetIconName(Block.Set.Icon.Name.Request request) throws Exception {
|
||||
byte[] encoded = Lib.blockSetIconName(request.toByteArray());
|
||||
Block.Set.Icon.Name.Response response = Block.Set.Icon.Name.Response.parseFrom(encoded);
|
||||
if (response.getError() != null && response.getError().getCode() != Block.Set.Icon.Name.Response.Error.Code.NULL) {
|
||||
throw new Exception(response.getError().getDescription());
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,4 +54,6 @@ public interface MiddlewareService {
|
|||
Block.Split.Response blockSplit(Block.Split.Request request) throws Exception;
|
||||
|
||||
BlockList.Duplicate.Response blockListDuplicate(BlockList.Duplicate.Request request) throws Exception;
|
||||
|
||||
Block.Set.Icon.Name.Response blockSetIconName(Block.Set.Icon.Name.Request request) throws Exception;
|
||||
}
|
||||
|
|
|
@ -26,9 +26,13 @@ dependencies {
|
|||
implementation project(':domain')
|
||||
implementation project(':core-utils')
|
||||
implementation project(':core-ui')
|
||||
implementation project(':library-page-icon-picker-widget')
|
||||
|
||||
def applicationDependencies = rootProject.ext.mainApplication
|
||||
def unitTestDependencies = rootProject.ext.unitTesting
|
||||
def lib = rootProject.ext.libraryPageIconPicker
|
||||
|
||||
implementation lib.emojiJava
|
||||
|
||||
implementation applicationDependencies.kotlin
|
||||
implementation applicationDependencies.coroutines
|
||||
|
@ -49,5 +53,4 @@ dependencies {
|
|||
testImplementation unitTestDependencies.archCoreTesting
|
||||
|
||||
androidTestImplementation 'androidx.test:core:1.2.0'
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package com.agileburo.anytype.presentation.common
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.agileburo.anytype.core_utils.common.EventWrapper
|
||||
|
||||
interface SupportCommand<Command> {
|
||||
val commands: MutableLiveData<EventWrapper<Command>>
|
||||
fun receive(): LiveData<EventWrapper<Command>> = commands
|
||||
fun dispatch(command: Command) {
|
||||
commands.postValue(EventWrapper(command))
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package com.agileburo.anytype.presentation.desktop
|
||||
|
||||
sealed class DashboardView {
|
||||
data class Document(val id: String, val title: String, val emoji: String = "") : DashboardView()
|
||||
data class Document(val id: String, val title: String, val emoji: String? = null) :
|
||||
DashboardView()
|
||||
}
|
|
@ -5,8 +5,10 @@ import com.agileburo.anytype.core_ui.features.page.BlockView
|
|||
import com.agileburo.anytype.domain.block.model.Block
|
||||
import com.agileburo.anytype.domain.block.model.Block.Content.Text.Style
|
||||
import com.agileburo.anytype.domain.dashboard.model.HomeDashboard
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.domain.misc.UrlBuilder
|
||||
import com.agileburo.anytype.presentation.desktop.DashboardView
|
||||
import com.vdurmont.emoji.EmojiManager
|
||||
|
||||
fun Block.toView(
|
||||
focused: Boolean = false,
|
||||
|
@ -43,7 +45,15 @@ fun Block.toView(
|
|||
)
|
||||
Style.TITLE -> BlockView.Title(
|
||||
id = id,
|
||||
text = content.text
|
||||
text = content.text,
|
||||
emoji = fields.icon?.let { name ->
|
||||
EmojiManager.getForAlias(
|
||||
name.substring(
|
||||
1,
|
||||
name.lastIndex
|
||||
)
|
||||
).unicode
|
||||
}
|
||||
)
|
||||
Style.QUOTE -> BlockView.Highlight(
|
||||
id = id,
|
||||
|
@ -179,8 +189,9 @@ private fun mapMarks(content: Block.Content.Text): List<Markup.Mark> =
|
|||
}
|
||||
}
|
||||
|
||||
fun HomeDashboard.toView(
|
||||
defaultTitle: String = "Untitled"
|
||||
suspend fun HomeDashboard.toView(
|
||||
defaultTitle: String = "Untitled",
|
||||
emojifier: Emojifier
|
||||
): List<DashboardView.Document> = children.mapNotNull { id ->
|
||||
blocks.find { block -> block.id == id }?.let { model ->
|
||||
when (val content = model.content) {
|
||||
|
@ -188,7 +199,13 @@ fun HomeDashboard.toView(
|
|||
if (content.type == Block.Content.Link.Type.PAGE) {
|
||||
DashboardView.Document(
|
||||
id = content.target,
|
||||
title = if (content.fields.hasName()) content.fields.name else defaultTitle
|
||||
title = if (content.fields.hasName()) content.fields.name else defaultTitle,
|
||||
emoji = content.fields.icon?.let { name ->
|
||||
if (name.isNotEmpty())
|
||||
emojifier.fromShortName(name).unicode
|
||||
else
|
||||
null
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.agileburo.anytype.domain.block.model.Block
|
|||
import com.agileburo.anytype.domain.event.model.Event
|
||||
import com.agileburo.anytype.domain.ext.content
|
||||
import com.agileburo.anytype.presentation.common.StateReducer
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Reduces external events (coming not from user, but from outside) to state.
|
||||
|
@ -44,6 +45,10 @@ class DocumentExternalEventReducer : StateReducer<List<Block>, Event> {
|
|||
},
|
||||
target = { block -> block.id == event.id }
|
||||
)
|
||||
else -> state
|
||||
is Event.Command.UpdateFields -> state.replace(
|
||||
replacement = { block -> block.copy(fields = event.fields) },
|
||||
target = { block -> block.id == event.target }
|
||||
)
|
||||
else -> state.also { Timber.d("Ignoring event: $event") }
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ import com.agileburo.anytype.domain.block.model.Block.Prototype
|
|||
import com.agileburo.anytype.domain.block.model.Position
|
||||
import com.agileburo.anytype.domain.common.Id
|
||||
import com.agileburo.anytype.domain.download.DownloadFile
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.domain.event.interactor.InterceptEvents
|
||||
import com.agileburo.anytype.domain.event.model.Event
|
||||
import com.agileburo.anytype.domain.ext.*
|
||||
|
@ -25,6 +26,7 @@ import com.agileburo.anytype.domain.page.ClosePage
|
|||
import com.agileburo.anytype.domain.page.CreatePage
|
||||
import com.agileburo.anytype.domain.page.OpenPage
|
||||
import com.agileburo.anytype.presentation.common.StateReducer
|
||||
import com.agileburo.anytype.presentation.common.SupportCommand
|
||||
import com.agileburo.anytype.presentation.mapper.toView
|
||||
import com.agileburo.anytype.presentation.navigation.AppNavigation
|
||||
import com.agileburo.anytype.presentation.navigation.SupportNavigation
|
||||
|
@ -54,9 +56,11 @@ class PageViewModel(
|
|||
private val splitBlock: SplitBlock,
|
||||
private val downloadFile: DownloadFile,
|
||||
private val documentExternalEventReducer: StateReducer<List<Block>, Event>,
|
||||
private val urlBuilder: UrlBuilder
|
||||
private val urlBuilder: UrlBuilder,
|
||||
private val emojifier: Emojifier
|
||||
) : ViewStateViewModel<PageViewModel.ViewState>(),
|
||||
SupportNavigation<EventWrapper<AppNavigation.Command>>,
|
||||
SupportCommand<PageViewModel.Command>,
|
||||
StateReducer<List<Block>, Event> by documentExternalEventReducer {
|
||||
|
||||
private val controlPanelInteractor = Interactor(viewModelScope)
|
||||
|
@ -91,6 +95,7 @@ class PageViewModel(
|
|||
val focus: LiveData<Id> = _focus
|
||||
|
||||
override val navigation = MutableLiveData<EventWrapper<AppNavigation.Command>>()
|
||||
override val commands = MutableLiveData<EventWrapper<Command>>()
|
||||
|
||||
init {
|
||||
startHandlingTextChanges()
|
||||
|
@ -255,16 +260,30 @@ class PageViewModel(
|
|||
|
||||
val render = models.asMap().asRender(context)
|
||||
|
||||
val page = models.first { it.id == context }
|
||||
|
||||
val numbers = render.numbers()
|
||||
|
||||
render.mapNotNull { block ->
|
||||
when (block.content) {
|
||||
when (val content = block.content) {
|
||||
is Content.Text -> {
|
||||
block.toView(
|
||||
focused = block.id == focus,
|
||||
numbers = numbers,
|
||||
urlBuilder = urlBuilder
|
||||
)
|
||||
if (content.style == Content.Text.Style.TITLE)
|
||||
BlockView.Title(
|
||||
id = block.id,
|
||||
text = content.text,
|
||||
emoji = page.fields.icon?.let { name ->
|
||||
if (name.isNotEmpty())
|
||||
emojifier.fromShortName(name).unicode
|
||||
else
|
||||
null
|
||||
}
|
||||
)
|
||||
else
|
||||
block.toView(
|
||||
focused = block.id == focus,
|
||||
numbers = numbers,
|
||||
urlBuilder = urlBuilder
|
||||
)
|
||||
}
|
||||
is Content.Image -> {
|
||||
block.toView(
|
||||
|
@ -841,6 +860,11 @@ class PageViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun onPageIconClicked() {
|
||||
val target = blocks.first { it.content is Content.Icon }.id
|
||||
dispatch(Command.OpenPagePicker(target))
|
||||
}
|
||||
|
||||
fun onDownloadFileClicked(id: String) {
|
||||
val block = blocks.first { it.id == id }
|
||||
val file = block.content<Content.File>()
|
||||
|
@ -874,8 +898,17 @@ class PageViewModel(
|
|||
object Loading : ViewState()
|
||||
data class Success(val blocks: List<BlockView>) : ViewState()
|
||||
data class Error(val message: String) : ViewState()
|
||||
data class OpenLinkScreen(val pageId: String, val block: Block, val range: IntRange) :
|
||||
ViewState()
|
||||
data class OpenLinkScreen(
|
||||
val pageId: String,
|
||||
val block: Block,
|
||||
val range: IntRange
|
||||
) : ViewState()
|
||||
}
|
||||
|
||||
sealed class Command {
|
||||
data class OpenPagePicker(
|
||||
val target: String
|
||||
) : Command()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
|
|||
import com.agileburo.anytype.domain.block.interactor.*
|
||||
import com.agileburo.anytype.domain.block.model.Block
|
||||
import com.agileburo.anytype.domain.download.DownloadFile
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.domain.event.interactor.InterceptEvents
|
||||
import com.agileburo.anytype.domain.event.model.Event
|
||||
import com.agileburo.anytype.domain.misc.UrlBuilder
|
||||
|
@ -32,7 +33,8 @@ open class PageViewModelFactory(
|
|||
private val splitBlock: SplitBlock,
|
||||
private val documentEventReducer: StateReducer<List<Block>, Event>,
|
||||
private val urlBuilder: UrlBuilder,
|
||||
private val downloadFile: DownloadFile
|
||||
private val downloadFile: DownloadFile,
|
||||
private val emojifier: Emojifier
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
@ -56,7 +58,8 @@ open class PageViewModelFactory(
|
|||
createPage = createPage,
|
||||
documentExternalEventReducer = documentEventReducer,
|
||||
urlBuilder = urlBuilder,
|
||||
downloadFile = downloadFile
|
||||
downloadFile = downloadFile,
|
||||
emojifier = emojifier
|
||||
) as T
|
||||
}
|
||||
}
|
|
@ -0,0 +1,342 @@
|
|||
package com.agileburo.anytype.presentation.page.picker
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.agileburo.anytype.core_utils.ui.ViewStateViewModel
|
||||
import com.agileburo.anytype.domain.base.Either
|
||||
import com.agileburo.anytype.domain.icon.SetIconName
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.model.PageIconPickerView
|
||||
import com.agileburo.anytype.presentation.common.StateReducer
|
||||
import com.agileburo.anytype.presentation.page.picker.PageIconPickerViewModel.Contract.Event
|
||||
import com.agileburo.anytype.presentation.page.picker.PageIconPickerViewModel.Contract.State
|
||||
import com.agileburo.anytype.presentation.page.picker.PageIconPickerViewModel.ViewState
|
||||
import com.vdurmont.emoji.Emoji
|
||||
import com.vdurmont.emoji.EmojiManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class PageIconPickerViewModel(
|
||||
private val setIconName: SetIconName
|
||||
) : ViewStateViewModel<ViewState>(), StateReducer<State, Event> {
|
||||
|
||||
private val channel = ConflatedBroadcastChannel<Event>()
|
||||
private val actions = Channel<Contract.Action>()
|
||||
private val flow: Flow<State> = channel.asFlow().scan(State.init(), function)
|
||||
|
||||
override val function: suspend (State, Event) -> State
|
||||
get() = { state, event -> reduce(state, event) }
|
||||
|
||||
|
||||
private val headers = listOf(
|
||||
PageIconPickerView.Action.UploadPhoto,
|
||||
PageIconPickerView.Action.PickRandomly,
|
||||
PageIconPickerView.Action.ChooseEmoji,
|
||||
PageIconPickerView.EmojiFilter
|
||||
)
|
||||
|
||||
init {
|
||||
processViewState()
|
||||
processActions()
|
||||
initialize()
|
||||
}
|
||||
|
||||
private fun initialize() {
|
||||
channel.offer(Event.Init)
|
||||
}
|
||||
|
||||
private fun processViewState() {
|
||||
flow
|
||||
.map { state ->
|
||||
when {
|
||||
state.error != null -> ViewState.Error(state.error)
|
||||
state.isLoading -> ViewState.Loading
|
||||
state.isCompleted -> ViewState.Exit
|
||||
else -> ViewState.Success(
|
||||
views = headers + map(state.selection)
|
||||
)
|
||||
}
|
||||
}
|
||||
.onEach { stateData.postValue(it) }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun processActions() {
|
||||
actions
|
||||
.consumeAsFlow()
|
||||
.map { action ->
|
||||
when (action) {
|
||||
is Contract.Action.FetchEmojis -> {
|
||||
val emojis = loadEmoji()
|
||||
Event.OnEmojiLoaded(emojis)
|
||||
}
|
||||
is Contract.Action.SearchEmojis -> {
|
||||
val emojis = findEmojis(action.query)
|
||||
Event.OnSearchResult(emojis)
|
||||
}
|
||||
is Contract.Action.SetEmoji -> {
|
||||
setIconEmojiName(action).let { result ->
|
||||
when (result) {
|
||||
is Either.Left -> Event.Failure(result.a)
|
||||
is Either.Right -> Event.OnCompleted
|
||||
}
|
||||
}
|
||||
}
|
||||
is Contract.Action.ClearEmoji -> {
|
||||
clearIconEmojiName(action).let { result ->
|
||||
when (result) {
|
||||
is Either.Left -> Event.Failure(result.a)
|
||||
is Either.Right -> Event.OnCompleted
|
||||
}
|
||||
}
|
||||
}
|
||||
is Contract.Action.PickRandomEmoji -> {
|
||||
val emoji = pickRandomEmoji(action.emojis)
|
||||
Event.OnRandomEmojiSelected(
|
||||
target = action.target,
|
||||
context = action.context,
|
||||
emoji = emoji
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEach(channel::send)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun setIconEmojiName(
|
||||
action: Contract.Action.SetEmoji
|
||||
): Either<Throwable, Unit> = withContext(Dispatchers.IO) {
|
||||
setIconName.run(
|
||||
params = SetIconName.Params(
|
||||
target = action.target,
|
||||
name = ":${action.alias}:",
|
||||
context = action.context
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun clearIconEmojiName(
|
||||
action: Contract.Action.ClearEmoji
|
||||
): Either<Throwable, Unit> = withContext(Dispatchers.IO) {
|
||||
setIconName.run(
|
||||
params = SetIconName.Params(
|
||||
target = action.target,
|
||||
name = "",
|
||||
context = action.context
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadEmoji(): List<Emoji> = withContext(Dispatchers.IO) {
|
||||
EmojiManager.getAll().toList()
|
||||
}
|
||||
|
||||
private suspend fun findEmojis(query: String): List<Emoji> = withContext(Dispatchers.IO) {
|
||||
EmojiManager.getAll().filter { emoji ->
|
||||
emoji.aliases.any { alias ->
|
||||
alias.contains(query, ignoreCase = true) || query == alias
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pickRandomEmoji(emojis: List<Emoji>): Emoji = withContext(Dispatchers.IO) {
|
||||
emojis.random()
|
||||
}
|
||||
|
||||
private suspend fun map(emojis: List<Emoji>) = withContext(Dispatchers.IO) {
|
||||
emojis.map { emoji ->
|
||||
PageIconPickerView.Emoji(
|
||||
alias = emoji.aliases.first(),
|
||||
unicode = emoji.unicode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: Event) {
|
||||
channel.offer(event)
|
||||
}
|
||||
|
||||
sealed class ViewState {
|
||||
object Loading : ViewState()
|
||||
object Exit : ViewState()
|
||||
data class Success(val views: List<PageIconPickerView>) : ViewState()
|
||||
data class Error(val message: String) : ViewState()
|
||||
}
|
||||
|
||||
sealed class Contract {
|
||||
|
||||
sealed class Action {
|
||||
|
||||
object FetchEmojis : Action()
|
||||
|
||||
data class SearchEmojis(
|
||||
val query: String
|
||||
) : Action()
|
||||
|
||||
data class PickRandomEmoji(
|
||||
val emojis: List<Emoji>,
|
||||
val context: String,
|
||||
val target: String
|
||||
) : Action()
|
||||
|
||||
data class ClearEmoji(
|
||||
val target: String,
|
||||
val context: String
|
||||
) : Action()
|
||||
|
||||
data class SetEmoji(
|
||||
val unicode: String,
|
||||
val alias: String,
|
||||
val target: String,
|
||||
val context: String
|
||||
) : Action()
|
||||
}
|
||||
|
||||
data class State(
|
||||
val isLoading: Boolean,
|
||||
val isCompleted: Boolean = false,
|
||||
val error: String? = null,
|
||||
val emojis: List<Emoji>,
|
||||
val selection: List<Emoji>
|
||||
) : Contract() {
|
||||
companion object {
|
||||
fun init() = State(
|
||||
isLoading = false,
|
||||
emojis = emptyList(),
|
||||
selection = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Event : Contract() {
|
||||
|
||||
object Init : Event()
|
||||
|
||||
data class OnEmojiClicked(
|
||||
val unicode: String,
|
||||
val alias: String,
|
||||
val target: String,
|
||||
val context: String
|
||||
) : Event()
|
||||
|
||||
data class OnFilterQueryChanged(
|
||||
val query: String
|
||||
) : Event()
|
||||
|
||||
data class OnSetRandomEmojiClicked(
|
||||
val target: String,
|
||||
val context: String
|
||||
) : Event()
|
||||
|
||||
data class OnEmojiLoaded(
|
||||
val emojis: List<Emoji>
|
||||
) : Event()
|
||||
|
||||
data class OnRandomEmojiSelected(
|
||||
val emoji: Emoji,
|
||||
val context: String,
|
||||
val target: String
|
||||
) : Event()
|
||||
|
||||
data class OnRemoveEmojiSelected(
|
||||
val context: String,
|
||||
val target: String
|
||||
) : Event()
|
||||
|
||||
data class OnSearchResult(
|
||||
val emojis: List<Emoji>
|
||||
) : Event()
|
||||
|
||||
object OnCompleted : Event()
|
||||
|
||||
data class Failure(val error: Throwable) : Event()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reduce(state: State, event: Event): State {
|
||||
return when (event) {
|
||||
is Event.Init -> state.copy(isLoading = true).also {
|
||||
actions.send(Contract.Action.FetchEmojis)
|
||||
}
|
||||
is Event.OnEmojiLoaded -> state.copy(
|
||||
isLoading = false,
|
||||
emojis = event.emojis,
|
||||
selection = event.emojis
|
||||
)
|
||||
is Event.OnSearchResult -> state.copy(
|
||||
isLoading = false,
|
||||
selection = event.emojis
|
||||
)
|
||||
is Event.OnRandomEmojiSelected -> state.copy(
|
||||
isLoading = true
|
||||
).also {
|
||||
actions.send(
|
||||
Contract.Action.SetEmoji(
|
||||
target = event.target,
|
||||
context = event.context,
|
||||
unicode = event.emoji.unicode,
|
||||
alias = event.emoji.aliases.first()
|
||||
)
|
||||
)
|
||||
}
|
||||
is Event.OnFilterQueryChanged -> state.copy(
|
||||
isLoading = true
|
||||
).also {
|
||||
actions.send(
|
||||
Contract.Action.SearchEmojis(
|
||||
query = event.query
|
||||
)
|
||||
)
|
||||
}
|
||||
is Event.OnEmojiClicked -> {
|
||||
state.copy(isLoading = true).also {
|
||||
actions.send(
|
||||
Contract.Action.SetEmoji(
|
||||
unicode = event.unicode,
|
||||
target = event.target,
|
||||
alias = event.alias,
|
||||
context = event.context
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
is Event.OnSetRandomEmojiClicked -> {
|
||||
state.copy(
|
||||
isLoading = true
|
||||
).also {
|
||||
actions.send(
|
||||
Contract.Action.PickRandomEmoji(
|
||||
emojis = state.emojis,
|
||||
target = event.target,
|
||||
context = event.context
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
is Event.OnRemoveEmojiSelected -> {
|
||||
state.copy(
|
||||
isLoading = true
|
||||
).also {
|
||||
actions.send(
|
||||
Contract.Action.ClearEmoji(
|
||||
context = event.context,
|
||||
target = event.target
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
is Event.OnCompleted -> state.copy(
|
||||
isLoading = false,
|
||||
isCompleted = true,
|
||||
error = null
|
||||
)
|
||||
is Event.Failure -> state.copy(
|
||||
isLoading = false,
|
||||
isCompleted = false,
|
||||
error = event.error.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package com.agileburo.anytype.presentation.page.picker
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.agileburo.anytype.domain.icon.SetIconName
|
||||
|
||||
class PageIconPickerViewModelFactory(
|
||||
private val setIconName: SetIconName
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T = PageIconPickerViewModel(
|
||||
setIconName = setIconName
|
||||
) as T
|
||||
}
|
|
@ -3,13 +3,30 @@ package com.agileburo.anytype.presentation.home
|
|||
import MockDataFactory
|
||||
import com.agileburo.anytype.domain.block.model.Block
|
||||
import com.agileburo.anytype.domain.dashboard.model.HomeDashboard
|
||||
import com.agileburo.anytype.domain.emoji.Emoji
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.presentation.desktop.DashboardView
|
||||
import com.agileburo.anytype.presentation.mapper.toView
|
||||
import com.nhaarman.mockitokotlin2.any
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.stub
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.Mock
|
||||
import org.mockito.MockitoAnnotations
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class HomeDashboardViewMapperTest {
|
||||
|
||||
@Mock
|
||||
lateinit var emojifier: Emojifier
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MockitoAnnotations.initMocks(this)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return empty list if home dashboard contains only data view`() {
|
||||
|
||||
|
@ -32,7 +49,11 @@ class HomeDashboardViewMapperTest {
|
|||
type = Block.Content.Dashboard.Type.MAIN_SCREEN
|
||||
)
|
||||
|
||||
val view = dashboard.toView()
|
||||
val view = runBlocking {
|
||||
dashboard.toView(
|
||||
emojifier = emojifier
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
expected = emptyList(),
|
||||
|
@ -43,11 +64,16 @@ class HomeDashboardViewMapperTest {
|
|||
@Test
|
||||
fun `should return one page link`() {
|
||||
|
||||
val emoji = Emoji(
|
||||
unicode = MockDataFactory.randomString(),
|
||||
alias = MockDataFactory.randomString()
|
||||
)
|
||||
|
||||
val child = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
content = Block.Content.Link(
|
||||
target = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty(),
|
||||
fields = Block.Fields(mapOf("icon" to emoji.name)),
|
||||
type = Block.Content.Link.Type.PAGE
|
||||
),
|
||||
children = emptyList(),
|
||||
|
@ -62,13 +88,18 @@ class HomeDashboardViewMapperTest {
|
|||
type = Block.Content.Dashboard.Type.MAIN_SCREEN
|
||||
)
|
||||
|
||||
val view = dashboard.toView()
|
||||
emojifier.stub {
|
||||
onBlocking { fromShortName(any()) } doReturn emoji
|
||||
}
|
||||
|
||||
val view: List<DashboardView> = runBlocking { dashboard.toView(emojifier = emojifier) }
|
||||
|
||||
assertEquals(
|
||||
expected = listOf(
|
||||
DashboardView.Document(
|
||||
id = child.content.asLink().target,
|
||||
title = "Untitled"
|
||||
title = "Untitled",
|
||||
emoji = emoji.unicode
|
||||
)
|
||||
),
|
||||
actual = view
|
||||
|
|
|
@ -16,6 +16,8 @@ import com.agileburo.anytype.domain.dashboard.interactor.CloseDashboard
|
|||
import com.agileburo.anytype.domain.dashboard.interactor.OpenDashboard
|
||||
import com.agileburo.anytype.domain.dashboard.interactor.toHomeDashboard
|
||||
import com.agileburo.anytype.domain.dashboard.model.HomeDashboard
|
||||
import com.agileburo.anytype.domain.emoji.Emoji
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.domain.event.interactor.InterceptEvents
|
||||
import com.agileburo.anytype.domain.event.model.Event
|
||||
import com.agileburo.anytype.domain.image.LoadImage
|
||||
|
@ -32,6 +34,7 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
@ -71,6 +74,9 @@ class HomeDashboardViewModelTest {
|
|||
@Mock
|
||||
lateinit var dnd: DragAndDrop
|
||||
|
||||
@Mock
|
||||
lateinit var emojifier: Emojifier
|
||||
|
||||
private lateinit var vm: HomeDashboardViewModel
|
||||
|
||||
@Before
|
||||
|
@ -242,8 +248,15 @@ class HomeDashboardViewModelTest {
|
|||
@Test
|
||||
fun `block dragging events do not alter overall state`() {
|
||||
|
||||
val config =
|
||||
Config(home = MockDataFactory.randomUuid(), gateway = MockDataFactory.randomUuid())
|
||||
val config = Config(
|
||||
home = MockDataFactory.randomUuid(),
|
||||
gateway = MockDataFactory.randomUuid()
|
||||
)
|
||||
|
||||
val emoji = Emoji(
|
||||
unicode = MockDataFactory.randomString(),
|
||||
alias = MockDataFactory.randomString()
|
||||
)
|
||||
|
||||
val pages = listOf(
|
||||
Block(
|
||||
|
@ -253,7 +266,12 @@ class HomeDashboardViewModelTest {
|
|||
content = Block.Content.Link(
|
||||
target = MockDataFactory.randomUuid(),
|
||||
type = Block.Content.Link.Type.PAGE,
|
||||
fields = Block.Fields(map = mapOf("name" to MockDataFactory.randomString()))
|
||||
fields = Block.Fields(
|
||||
map = mapOf(
|
||||
"name" to MockDataFactory.randomString(),
|
||||
"icon" to MockDataFactory.randomString()
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
Block(
|
||||
|
@ -263,7 +281,12 @@ class HomeDashboardViewModelTest {
|
|||
content = Block.Content.Link(
|
||||
target = MockDataFactory.randomUuid(),
|
||||
type = Block.Content.Link.Type.PAGE,
|
||||
fields = Block.Fields(map = mapOf("name" to MockDataFactory.randomString()))
|
||||
fields = Block.Fields(
|
||||
map = mapOf(
|
||||
"name" to MockDataFactory.randomString(),
|
||||
"icon" to emoji.name
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -274,7 +297,8 @@ class HomeDashboardViewModelTest {
|
|||
type = Block.Content.Dashboard.Type.MAIN_SCREEN
|
||||
),
|
||||
children = pages.map { page -> page.id },
|
||||
fields = Block.Fields(map = mapOf("name" to MockDataFactory.randomString()))
|
||||
|
||||
fields = Block.Fields.empty()
|
||||
)
|
||||
|
||||
val delayInMillis = 100L
|
||||
|
@ -292,6 +316,10 @@ class HomeDashboardViewModelTest {
|
|||
)
|
||||
}
|
||||
|
||||
emojifier.stub {
|
||||
onBlocking { fromShortName(any()) } doReturn emoji
|
||||
}
|
||||
|
||||
stubGetConfig(Either.Right(config))
|
||||
stubObserveEvents(
|
||||
params = InterceptEvents.Params(context = null),
|
||||
|
@ -316,8 +344,15 @@ class HomeDashboardViewModelTest {
|
|||
error = null
|
||||
)
|
||||
|
||||
val views =
|
||||
listOf(dashboard, pages.first(), pages.last()).toHomeDashboard(dashboard.id).toView()
|
||||
val views = runBlocking {
|
||||
listOf(
|
||||
dashboard,
|
||||
pages.first(),
|
||||
pages.last()
|
||||
).toHomeDashboard(dashboard.id).toView(
|
||||
emojifier = emojifier
|
||||
)
|
||||
}
|
||||
|
||||
val from = 0
|
||||
val to = 1
|
||||
|
@ -337,8 +372,15 @@ class HomeDashboardViewModelTest {
|
|||
@Test
|
||||
fun `should start dispatching drag-and-drop actions when the dragged item is dropped`() {
|
||||
|
||||
val config =
|
||||
Config(home = MockDataFactory.randomUuid(), gateway = MockDataFactory.randomUuid())
|
||||
val config = Config(
|
||||
home = MockDataFactory.randomUuid(),
|
||||
gateway = MockDataFactory.randomUuid()
|
||||
)
|
||||
|
||||
val emoji = Emoji(
|
||||
unicode = MockDataFactory.randomString(),
|
||||
alias = MockDataFactory.randomString()
|
||||
)
|
||||
|
||||
val pages = listOf(
|
||||
Block(
|
||||
|
@ -348,7 +390,12 @@ class HomeDashboardViewModelTest {
|
|||
content = Block.Content.Link(
|
||||
type = Block.Content.Link.Type.PAGE,
|
||||
target = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty()
|
||||
fields = Block.Fields(
|
||||
map = mapOf(
|
||||
"name" to MockDataFactory.randomString(),
|
||||
"icon" to emoji.name
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
Block(
|
||||
|
@ -358,7 +405,12 @@ class HomeDashboardViewModelTest {
|
|||
content = Block.Content.Link(
|
||||
type = Block.Content.Link.Type.PAGE,
|
||||
target = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields.empty()
|
||||
fields = Block.Fields(
|
||||
map = mapOf(
|
||||
"name" to MockDataFactory.randomString(),
|
||||
"icon" to emoji.name
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -378,6 +430,10 @@ class HomeDashboardViewModelTest {
|
|||
emit(dashboard)
|
||||
}
|
||||
|
||||
emojifier.stub {
|
||||
onBlocking { fromShortName(any()) } doReturn emoji
|
||||
}
|
||||
|
||||
stubGetConfig(Either.Right(config))
|
||||
stubObserveEvents(params = InterceptEvents.Params(context = null))
|
||||
stubOpenDashboard()
|
||||
|
@ -388,7 +444,11 @@ class HomeDashboardViewModelTest {
|
|||
|
||||
coroutineTestRule.advanceTime(delayInMillis)
|
||||
|
||||
val views = dashboard.toView()
|
||||
val views = runBlocking {
|
||||
dashboard.toView(
|
||||
emojifier = emojifier
|
||||
)
|
||||
}
|
||||
|
||||
val from = 0
|
||||
val to = 1
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.agileburo.anytype.domain.block.model.Block
|
|||
import com.agileburo.anytype.domain.block.model.Position
|
||||
import com.agileburo.anytype.domain.config.Config
|
||||
import com.agileburo.anytype.domain.download.DownloadFile
|
||||
import com.agileburo.anytype.domain.emoji.Emojifier
|
||||
import com.agileburo.anytype.domain.event.interactor.InterceptEvents
|
||||
import com.agileburo.anytype.domain.event.model.Event
|
||||
import com.agileburo.anytype.domain.ext.content
|
||||
|
@ -96,6 +97,9 @@ class PageViewModelTest {
|
|||
@Mock
|
||||
lateinit var downloadFile: DownloadFile
|
||||
|
||||
@Mock
|
||||
lateinit var emojifier: Emojifier
|
||||
|
||||
private lateinit var vm: PageViewModel
|
||||
|
||||
@Before
|
||||
|
@ -2840,7 +2844,8 @@ class PageViewModelTest {
|
|||
splitBlock = splitBlock,
|
||||
documentExternalEventReducer = DocumentExternalEventReducer(),
|
||||
urlBuilder = urlBuilder,
|
||||
downloadFile = downloadFile
|
||||
downloadFile = downloadFile,
|
||||
emojifier = emojifier
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
package com.agileburo.anytype.sample
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.agileburo.anytype.sample", appContext.packageName)
|
||||
}
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
package com.agileburo.anytype.sample
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.model.PageIconPickerView
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.ui.PageIconPickerAdapter
|
||||
import com.agileburo.anytype.library_page_icon_picker_widget.ui.PageIconPickerViewHolder
|
||||
import com.vdurmont.emoji.EmojiManager
|
||||
import kotlinx.android.synthetic.main.sample_page_icon_picker_activity.*
|
||||
|
||||
class PageIconPickerSampleActivity : AppCompatActivity(R.layout.sample_page_icon_picker_activity) {
|
||||
|
||||
private val emojis by lazy {
|
||||
EmojiManager
|
||||
.getAll()
|
||||
.map { emoji ->
|
||||
PageIconPickerView.Emoji(
|
||||
unicode = emoji.unicode
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val pageIconPickerAdapter = PageIconPickerAdapter(
|
||||
views = listOf(
|
||||
PageIconPickerView.Action.UploadPhoto,
|
||||
PageIconPickerView.Action.PickRandomly,
|
||||
PageIconPickerView.Action.ChooseEmoji,
|
||||
PageIconPickerView.EmojiFilter
|
||||
) + emojis,
|
||||
onUploadPhotoClicked = {},
|
||||
onSetRandomEmojiClicked = {},
|
||||
onFilterQueryChanged = {}
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
recyler.apply {
|
||||
setItemViewCacheSize(100)
|
||||
setHasFixedSize(true)
|
||||
layoutManager = GridLayoutManager(context, PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT).apply {
|
||||
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int) =
|
||||
when (val type = pageIconPickerAdapter.getItemViewType(position)) {
|
||||
PageIconPickerViewHolder.HOLDER_UPLOAD_PHOTO -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_CHOOSE_EMOJI -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_PICK_RANDOM_EMOJI -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_EMOJI_CATEGORY_HEADER -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_EMOJI_FILTER -> PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT
|
||||
PageIconPickerViewHolder.HOLDER_EMOJI_ITEM -> 1
|
||||
else -> throw IllegalStateException("Unexpected view type: $type")
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter = pageIconPickerAdapter.apply {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PAGE_ICON_PICKER_DEFAULT_SPAN_COUNT = 8
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout 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="match_parent"
|
||||
android:background="#EEAFAF">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/rectangle_debug_background"
|
||||
android:orientation="vertical"
|
||||
app:layout_behavior="@string/bottom_sheet_behavior">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/dragger"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="6dp"
|
||||
android:background="@drawable/page_icon_picker_dragger_background" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="30dp"
|
||||
android:fontFamily="@font/graphik_semibold"
|
||||
android:text="Page icon"
|
||||
android:textColor="@color/black"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="30dp"
|
||||
android:text="Remove"
|
||||
android:textColor="#ACA996"
|
||||
android:textSize="17sp" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyler"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:minHeight="100dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -1,17 +0,0 @@
|
|||
package com.agileburo.anytype.sample
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
include ':app',
|
||||
include ':app', ':library-emojifier',
|
||||
':sample',
|
||||
':core-utils',
|
||||
':middleware',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue