1
0
Fork 0
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:
Evgenii Kozlov 2020-03-10 17:41:25 +03:00 committed by GitHub
parent b4a3401671
commit ab1b602176
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 1206 additions and 230 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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),

View file

@ -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"
}
}

View file

@ -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,

View 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>

View file

@ -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>

View file

@ -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 -> {

View file

@ -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 {

View file

@ -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" />

View file

@ -847,7 +847,8 @@ class BlockAdapterTest {
onFooterClicked = {},
onPageClicked = {},
onTextInputClicked = {},
onDownloadFileClicked = {}
onDownloadFileClicked = {},
onPageIconClicked = {}
)
}
}

View file

@ -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)
)
}
}
}

View file

@ -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 }
}

View file

@ -71,4 +71,10 @@ class CommandEntity {
val target: String,
val index: Int
)
data class SetIconName(
val context: String,
val target: String,
val name: String
)
}

View file

@ -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()
}
}

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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
)
}

View file

@ -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)
}

View file

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

View file

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

View file

@ -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()
}
}

View file

@ -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
View file

@ -0,0 +1 @@
/build

View 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
}

View file

21
library-emojifier/proguard-rules.pro vendored Normal file
View 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

View file

@ -0,0 +1 @@
<manifest package="com.agileburo.anytype.emojifier" />

View file

@ -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)
}
}

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Library Emojifier</string>
</resources>

View file

@ -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

View file

@ -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)
}
}

View file

@ -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) }
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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);
}
}

View file

@ -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
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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'
}

View file

@ -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))
}
}

View file

@ -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()
}

View file

@ -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

View file

@ -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") }
}
}

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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()
)
}
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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
)
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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>

View file

@ -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)
}
}

View file

@ -1,4 +1,4 @@
include ':app',
include ':app', ':library-emojifier',
':sample',
':core-utils',
':middleware',