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

Wire action toolbar with middleware (#115)

* Implemented block duplication
* Focusing new paragraph when created
* Refactored tests, also added new tests for block deletion operations
This commit is contained in:
ubu 2020-01-17 20:09:12 +03:00 committed by GitHub
parent c8d09dcef1
commit d5864a0d6d
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 910 additions and 243 deletions

View file

@ -1,9 +1,7 @@
package com.agileburo.anytype.di.feature
import com.agileburo.anytype.core_utils.di.scope.PerScreen
import com.agileburo.anytype.domain.block.interactor.CreateBlock
import com.agileburo.anytype.domain.block.interactor.UpdateBlock
import com.agileburo.anytype.domain.block.interactor.UpdateCheckbox
import com.agileburo.anytype.domain.block.interactor.*
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.event.interactor.ObserveEvents
import com.agileburo.anytype.domain.page.ClosePage
@ -40,14 +38,18 @@ class PageModule {
updateBlock: UpdateBlock,
createBlock: CreateBlock,
observeEvents: ObserveEvents,
updateCheckbox: UpdateCheckbox
updateCheckbox: UpdateCheckbox,
unlinkBlocks: UnlinkBlocks,
duplicateBlock: DuplicateBlock
): PageViewModelFactory = PageViewModelFactory(
openPage = openPage,
closePage = closePage,
updateBlock = updateBlock,
createBlock = createBlock,
observeEvents = observeEvents,
updateCheckbox = updateCheckbox
updateCheckbox = updateCheckbox,
unlinkBlocks = unlinkBlocks,
duplicateBlock = duplicateBlock
)
@Provides
@ -106,4 +108,20 @@ class PageModule {
): UpdateCheckbox = UpdateCheckbox(
repo = repo
)
@Provides
@PerScreen
fun provideUnlinkBlocksUseCase(
repo: BlockRepository
): UnlinkBlocks = UnlinkBlocks(
repo = repo
)
@Provides
@PerScreen
fun provideDuplicateBlockUseCase(
repo: BlockRepository
): DuplicateBlock = DuplicateBlock(
repo = repo
)
}

View file

@ -13,6 +13,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.agileburo.anytype.R
import com.agileburo.anytype.core_ui.features.page.BlockAdapter
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.core_ui.widgets.toolbar.ActionToolbarWidget.ActionConfig.ACTION_DELETE
import com.agileburo.anytype.core_ui.widgets.toolbar.ActionToolbarWidget.ActionConfig.ACTION_DUPLICATE
import com.agileburo.anytype.core_ui.widgets.toolbar.ColorToolbarWidget
import com.agileburo.anytype.core_ui.widgets.toolbar.OptionToolbarWidget.Option
import com.agileburo.anytype.core_ui.widgets.toolbar.OptionToolbarWidget.OptionConfig.OPTION_LIST_BULLETED_LIST
@ -35,6 +37,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -50,13 +53,9 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
marks = editable.extractMarks()
)
},
onSelectionChanged = { id, selection ->
vm.onSelectionChanged(
id = id,
selection = selection
)
},
onCheckboxClicked = vm::onCheckboxClicked
onSelectionChanged = vm::onSelectionChanged,
onCheckboxClicked = vm::onCheckboxClicked,
onFocusChanged = vm::onBlockFocusChanged
)
}
@ -104,6 +103,11 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
.onEach { vm.onAddBlockToolbarClicked() }
.launchIn(lifecycleScope)
toolbar
.actionClicks()
.onEach { vm.onActionToolbarClicked() }
.launchIn(lifecycleScope)
markupToolbar
.markupClicks()
.onEach { vm.onMarkupActionClicked(it) }
@ -156,6 +160,16 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
}
.launchIn(lifecycleScope)
actionToolbar
.actionClicks()
.onEach { action ->
when (action.type) {
ACTION_DELETE -> vm.onActionDeleteClicked()
ACTION_DUPLICATE -> vm.onActionDuplicateClicked()
else -> toast(NOT_IMPLEMENTED_MESSAGE)
}
}
.launchIn(lifecycleScope)
}
private fun showColorToolbar() {
@ -194,6 +208,7 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
}
private fun render(state: ControlPanelState) {
Timber.d("Rendering new control panel state:\n$state")
markupToolbar.setState(state.markupToolbar)
toolbar.setState(state.blockToolbar)
with(state.colorToolbar) {
@ -216,6 +231,16 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
} else
hideOptionToolbar()
}
with(state.actionToolbar) {
if (isVisible) {
hideSoftInput()
lifecycleScope.launch {
delay(300)
actionToolbar.show()
}
} else
actionToolbar.hide()
}
}
override fun injectDependencies() {

View file

@ -65,6 +65,13 @@
android:layout_gravity="bottom"
android:visibility="invisible" />
<com.agileburo.anytype.core_ui.widgets.toolbar.ActionToolbarWidget
android:id="@+id/actionToolbar"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="bottom"
android:visibility="invisible" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -37,7 +37,8 @@ class BlockAdapter(
private var blocks: List<BlockView>,
private val onTextChanged: (String, Editable) -> Unit,
private val onSelectionChanged: (String, IntRange) -> Unit,
private val onCheckboxClicked: (String) -> Unit
private val onCheckboxClicked: (String) -> Unit,
private val onFocusChanged: (String, Boolean) -> Unit
) : RecyclerView.Adapter<BlockViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BlockViewHolder {
@ -252,31 +253,36 @@ class BlockAdapter(
holder.bind(
item = blocks[position] as BlockView.Text,
onTextChanged = onTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
onFocusChanged = onFocusChanged
)
}
is BlockViewHolder.Title -> {
holder.bind(
item = blocks[position] as BlockView.Title,
onTextChanged = onTextChanged
onTextChanged = onTextChanged,
onFocusChanged = onFocusChanged
)
}
is BlockViewHolder.HeaderOne -> {
holder.bind(
item = blocks[position] as BlockView.HeaderOne,
onTextChanged = onTextChanged
onTextChanged = onTextChanged,
onFocusChanged = onFocusChanged
)
}
is BlockViewHolder.HeaderTwo -> {
holder.bind(
item = blocks[position] as BlockView.HeaderTwo,
onTextChanged = onTextChanged
onTextChanged = onTextChanged,
onFocusChanged = onFocusChanged
)
}
is BlockViewHolder.HeaderThree -> {
holder.bind(
item = blocks[position] as BlockView.HeaderThree,
onTextChanged = onTextChanged
onTextChanged = onTextChanged,
onFocusChanged = onFocusChanged
)
}
is BlockViewHolder.Code -> {
@ -289,7 +295,8 @@ class BlockAdapter(
item = blocks[position] as BlockView.Checkbox,
onTextChanged = onTextChanged,
onCheckboxClicked = onCheckboxClicked,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
onFocusChanged = onFocusChanged
)
}
is BlockViewHolder.Task -> {
@ -301,7 +308,8 @@ class BlockAdapter(
holder.bind(
item = blocks[position] as BlockView.Bulleted,
onTextChanged = onTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
onFocusChanged = onFocusChanged
)
}
is BlockViewHolder.Numbered -> {
@ -342,7 +350,8 @@ class BlockAdapter(
is BlockViewHolder.Highlight -> {
holder.bind(
item = blocks[position] as BlockView.Highlight,
onTextChanged = onTextChanged
onTextChanged = onTextChanged,
onFocusChanged = onFocusChanged
)
}
}

View file

@ -64,10 +64,15 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(
item: BlockView.Text,
onTextChanged: (String, Editable) -> Unit,
onSelectionChanged: (String, IntRange) -> Unit
onSelectionChanged: (String, IntRange) -> Unit,
onFocusChanged: (String, Boolean) -> Unit
) {
logOnBind()
content.setOnFocusChangeListener { _, hasFocus ->
onFocusChanged(item.id, hasFocus)
}
if (item.marks.isNotEmpty())
content.setText(item.toSpannable(), TextView.BufferType.SPANNABLE)
else
@ -123,9 +128,15 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(
item: BlockView.Title,
onTextChanged: (String, Editable) -> Unit
onTextChanged: (String, Editable) -> Unit,
onFocusChanged: (String, Boolean) -> Unit
) {
title.setOnFocusChangeListener { _, hasFocus ->
onFocusChanged(item.id, hasFocus)
}
title.setText(item.text)
title.addTextChangedListener(
DefaultTextWatcher { text ->
onTextChanged(item.id, text)
@ -140,8 +151,12 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(
item: BlockView.HeaderOne,
onTextChanged: (String, Editable) -> Unit
onTextChanged: (String, Editable) -> Unit,
onFocusChanged: (String, Boolean) -> Unit
) {
header.setOnFocusChangeListener { _, hasFocus ->
onFocusChanged(item.id, hasFocus)
}
header.setText(item.text)
header.addTextChangedListener(
DefaultTextWatcher { text ->
@ -157,8 +172,12 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(
item: BlockView.HeaderTwo,
onTextChanged: (String, Editable) -> Unit
onTextChanged: (String, Editable) -> Unit,
onFocusChanged: (String, Boolean) -> Unit
) {
header.setOnFocusChangeListener { _, hasFocus ->
onFocusChanged(item.id, hasFocus)
}
header.setText(item.text)
header.addTextChangedListener(
DefaultTextWatcher { text ->
@ -174,8 +193,12 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(
item: BlockView.HeaderThree,
onTextChanged: (String, Editable) -> Unit
onTextChanged: (String, Editable) -> Unit,
onFocusChanged: (String, Boolean) -> Unit
) {
header.setOnFocusChangeListener { _, hasFocus ->
onFocusChanged(item.id, hasFocus)
}
header.setText(item.text)
header.addTextChangedListener(
DefaultTextWatcher { text ->
@ -203,8 +226,12 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
item: BlockView.Checkbox,
onTextChanged: (String, Editable) -> Unit,
onCheckboxClicked: (String) -> Unit,
onSelectionChanged: (String, IntRange) -> Unit
onSelectionChanged: (String, IntRange) -> Unit,
onFocusChanged: (String, Boolean) -> Unit
) {
content.setOnFocusChangeListener { _, hasFocus ->
onFocusChanged(item.id, hasFocus)
}
checkbox.isSelected = item.checked
@ -283,10 +310,15 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(
item: BlockView.Bulleted,
onTextChanged: (String, Editable) -> Unit,
onSelectionChanged: (String, IntRange) -> Unit
onSelectionChanged: (String, IntRange) -> Unit,
onFocusChanged: (String, Boolean) -> Unit
) {
Timber.d("Binding bullet")
content.setOnFocusChangeListener { _, hasFocus ->
onFocusChanged(item.id, hasFocus)
}
if (item.marks.isNotEmpty())
content.setText(item.toSpannable(), TextView.BufferType.SPANNABLE)
else
@ -440,8 +472,12 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(
item: BlockView.Highlight,
onTextChanged: (String, Editable) -> Unit
onTextChanged: (String, Editable) -> Unit,
onFocusChanged: (String, Boolean) -> Unit
) {
content.setOnFocusChangeListener { _, hasFocus ->
onFocusChanged(item.id, hasFocus)
}
content.setText(item.text)
content.addTextChangedListener(
DefaultTextWatcher { text ->
@ -449,7 +485,6 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
}
)
}
}
companion object {

View file

@ -3,17 +3,22 @@ package com.agileburo.anytype.core_ui.state
/**
* Control panels are UI-elements that allow user to interact with blocks on a page.
* Each panel is currently represented as a toolbar.
* @property focus block currently associated with the control panel (if not present, control panel is not active)
* @property blockToolbar block-toolbar state (main toolbar state)
* @property markupToolbar markup toolbar state
* @property colorToolbar color toolbar state
* @property addBlockToolbar add-block toolbar state
* @property actionToolbar action-toolbar state
*/
data class ControlPanelState(
val focus: Focus? = null,
val blockToolbar: Toolbar.Block,
val markupToolbar: Toolbar.Markup,
val colorToolbar: Toolbar.Color,
val addBlockToolbar: Toolbar.AddBlock
val addBlockToolbar: Toolbar.AddBlock,
val actionToolbar: Toolbar.BlockAction
) {
sealed class Toolbar {
/**
@ -64,21 +69,27 @@ data class ControlPanelState(
override val isVisible: Boolean
) : Toolbar()
/**
* Basic action (delete, duplicate, undo, redo, etc.) toolbar state
*/
data class BlockAction(
override val isVisible: Boolean
) : Toolbar()
/**
* TODO
*/
data class TurnInto(
val isVisible: Boolean
)
/**
* TODO
*/
data class BlockAction(
val isVisible: Boolean
)
}
/**
* Block currently associated with this panel.
* @property id id of the focused block
*/
data class Focus(val id: String)
companion object {
/**
@ -98,7 +109,11 @@ data class ControlPanelState(
),
addBlockToolbar = Toolbar.AddBlock(
isVisible = false
)
),
actionToolbar = Toolbar.BlockAction(
isVisible = false
),
focus = null
)
}
}

View file

@ -5,10 +5,14 @@ import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.agileburo.anytype.core_ui.R
import com.agileburo.anytype.core_ui.extensions.invisible
import com.agileburo.anytype.core_ui.extensions.visible
import com.agileburo.anytype.core_ui.layout.SpacingItemDecoration
import com.agileburo.anytype.core_ui.widgets.toolbar.ActionToolbarWidget.Action
import com.agileburo.anytype.core_ui.widgets.toolbar.ActionToolbarWidget.ActionAdapter
@ -21,6 +25,10 @@ import com.agileburo.anytype.core_ui.widgets.toolbar.ActionToolbarWidget.ActionC
import kotlinx.android.synthetic.main.item_toolbar_action.view.*
import kotlinx.android.synthetic.main.item_toolbar_action.view.title
import kotlinx.android.synthetic.main.widget_action_toolbar.view.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.sendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow
/**
* This toolbar widget provides user with different types of actions applicable to blocks.
@ -30,6 +38,8 @@ import kotlinx.android.synthetic.main.widget_action_toolbar.view.*
*/
class ActionToolbarWidget : LinearLayout {
private val channel = Channel<Action>()
constructor(
context: Context
) : this(context, null)
@ -48,6 +58,7 @@ class ActionToolbarWidget : LinearLayout {
}
private fun inflate() {
// TODO remove redundant linear layout
LayoutInflater.from(context).inflate(R.layout.widget_action_toolbar, this)
setupAdapter()
}
@ -89,13 +100,24 @@ class ActionToolbarWidget : LinearLayout {
Action(ACTION_UNDO),
Action(ACTION_REDO)
),
onActionClicked = { action ->
// do nothing
}
onActionClicked = channel::sendBlocking
)
}
}
fun actionClicks(): Flow<Action> = channel.consumeAsFlow()
fun show() {
layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
visible()
}
fun hide() {
layoutParams = LayoutParams(MATCH_PARENT, 0)
invisible()
}
/**
* Adapter for rendering list of actions
* @property actions immutable list of actions

View file

@ -8,6 +8,7 @@ import com.agileburo.anytype.core_ui.R
import com.agileburo.anytype.core_ui.reactive.clicks
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.core_ui.state.ControlPanelState.Toolbar.Block.Action.ADD
import com.agileburo.anytype.core_ui.state.ControlPanelState.Toolbar.Block.Action.BLOCK_ACTION
import kotlinx.android.synthetic.main.widget_block_toolbar.view.*
class BlockToolbarWidget : ConstraintLayout {
@ -35,8 +36,10 @@ class BlockToolbarWidget : ConstraintLayout {
fun keyboardClicks() = keyboard.clicks()
fun addButtonClicks() = add.clicks()
fun actionClicks() = actions.clicks()
fun setState(state: ControlPanelState.Toolbar.Block) {
add.isSelected = state.selectedAction == ADD
actions.isSelected = state.selectedAction == BLOCK_ACTION
}
}

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M19,5H15V9H19V5ZM15,3C13.8954,3 13,3.8954 13,5V9C13,10.1046 13.8954,11 15,11H19C20.1046,11 21,10.1046 21,9V5C21,3.8954 20.1046,3 19,3H15Z" />
<path
android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M9,5H5V9H9V5ZM5,3C3.8954,3 3,3.8954 3,5V9C3,10.1046 3.8954,11 5,11H9C10.1046,11 11,10.1046 11,9V5C11,3.8954 10.1046,3 9,3H5Z" />
<path
android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M19,15.0115H15V19.0115H19V15.0115ZM15,13.0115C13.8954,13.0115 13,13.9069 13,15.0115V19.0115C13,20.116 13.8954,21.0115 15,21.0115H19C20.1046,21.0115 21,20.116 21,19.0115V15.0115C21,13.9069 20.1046,13.0115 19,13.0115H15Z" />
<path
android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M9,15.0115H5V19.0115H9V15.0115ZM5,13.0115C3.8954,13.0115 3,13.9069 3,15.0115V19.0115C3,20.116 3.8954,21.0115 5,21.0115H9C10.1046,21.0115 11,20.116 11,19.0115V15.0115C11,13.9069 10.1046,13.0115 9,13.0115H5Z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_toolbar_actions" android:state_selected="false" />
<item android:drawable="@drawable/ic_toolbar_actions_selected" android:state_selected="true" />
</selector>

View file

@ -17,13 +17,19 @@
android:textSize="15sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/actionRecycler"
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="13dp"
android:layout_marginBottom="13dp"
tools:itemCount="1"
tools:listitem="@layout/item_toolbar_action" />
android:layout_marginBottom="13dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/actionRecycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:itemCount="1"
tools:listitem="@layout/item_toolbar_action" />
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View file

@ -25,6 +25,7 @@
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:clickable="true"
android:focusable="true"
android:contentDescription="@string/content_description_hide_keyboard_button"
android:src="@drawable/ic_toolbar_keyboard"
android:stateListAnimator="@animator/scale_shrink"
@ -38,8 +39,9 @@
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:clickable="true"
android:focusable="true"
android:contentDescription="@string/content_description_show_actions"
android:src="@drawable/ic_toolbar_actions"
android:src="@drawable/ic_toolbar_actions_selector"
android:stateListAnimator="@animator/scale_shrink"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/keyboard"
@ -51,6 +53,7 @@
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:clickable="true"
android:focusable="true"
android:contentDescription="@string/content_description_show_colors"
android:src="@drawable/ic_toolbar_color"
android:stateListAnimator="@animator/scale_shrink"

View file

@ -219,6 +219,16 @@ fun Command.Dnd.toEntity(): CommandEntity.Dnd {
)
}
fun Command.Unlink.toEntity(): CommandEntity.Unlink = CommandEntity.Unlink(
context = context,
targets = targets
)
fun Command.Duplicate.toEntity(): CommandEntity.Duplicate = CommandEntity.Duplicate(
context = context,
original = original
)
fun Position.toEntity(): PositionEntity {
return PositionEntity.valueOf(name)
}
@ -249,6 +259,11 @@ fun EventEntity.toDomain(): Event {
children = children
)
}
is EventEntity.Command.DeleteBlock -> {
Event.Command.DeleteBlock(
target = target
)
}
}
}

View file

@ -1,5 +1,8 @@
package com.agileburo.anytype.data.auth.model
/**
* For documentation, please refer to domain models description.
*/
class CommandEntity {
class UpdateText(
@ -29,4 +32,14 @@ class CommandEntity {
val blockIds: List<String>,
val position: PositionEntity
)
class Duplicate(
val context: String,
val original: String
)
class Unlink(
val context: String,
val targets: List<String>
)
}

View file

@ -23,5 +23,9 @@ sealed class EventEntity {
val id: String,
val children: List<String>
) : Command()
data class DeleteBlock(
val target: String
) : Command()
}
}

View file

@ -52,4 +52,11 @@ class BlockDataRepository(
override suspend fun dnd(command: Command.Dnd) {
factory.remote.dnd(command.toEntity())
}
override suspend fun duplicate(command: Command.Duplicate) =
factory.remote.duplicate(command.toEntity())
override suspend fun unlink(command: Command.Unlink) {
factory.remote.unlink(command.toEntity())
}
}

View file

@ -4,6 +4,7 @@ import com.agileburo.anytype.data.auth.model.BlockEntity
import com.agileburo.anytype.data.auth.model.CommandEntity
import com.agileburo.anytype.data.auth.model.ConfigEntity
import com.agileburo.anytype.data.auth.model.EventEntity
import com.agileburo.anytype.domain.common.Id
import kotlinx.coroutines.flow.Flow
interface BlockDataStore {
@ -11,6 +12,8 @@ interface BlockDataStore {
suspend fun updateText(command: CommandEntity.UpdateText)
suspend fun updateCheckbox(command: CommandEntity.UpdateCheckbox)
suspend fun dnd(command: CommandEntity.Dnd)
suspend fun duplicate(command: CommandEntity.Duplicate): Id
suspend fun unlink(command: CommandEntity.Unlink)
suspend fun getConfig(): ConfigEntity
suspend fun createPage(parentId: String): String
suspend fun openPage(id: String)

View file

@ -4,6 +4,7 @@ import com.agileburo.anytype.data.auth.model.BlockEntity
import com.agileburo.anytype.data.auth.model.CommandEntity
import com.agileburo.anytype.data.auth.model.ConfigEntity
import com.agileburo.anytype.data.auth.model.EventEntity
import com.agileburo.anytype.domain.common.Id
import kotlinx.coroutines.flow.Flow
interface BlockRemote {
@ -11,6 +12,8 @@ interface BlockRemote {
suspend fun updateText(command: CommandEntity.UpdateText)
suspend fun updateCheckbox(command: CommandEntity.UpdateCheckbox)
suspend fun dnd(command: CommandEntity.Dnd)
suspend fun duplicate(command: CommandEntity.Duplicate): Id
suspend fun unlink(command: CommandEntity.Unlink)
suspend fun getConfig(): ConfigEntity
suspend fun createPage(parentId: String): String
suspend fun openPage(id: String)

View file

@ -42,4 +42,10 @@ class BlockRemoteDataStore(private val remote: BlockRemote) : BlockDataStore {
override suspend fun dnd(command: CommandEntity.Dnd) {
remote.dnd(command)
}
override suspend fun duplicate(command: CommandEntity.Duplicate) = remote.duplicate(command)
override suspend fun unlink(command: CommandEntity.Unlink) {
remote.unlink(command)
}
}

View file

@ -0,0 +1,37 @@
package com.agileburo.anytype.domain.block.interactor
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 block duplication.
* Should return id of the new block.
*/
class DuplicateBlock(private val repo: BlockRepository) :
BaseUseCase<Id, DuplicateBlock.Params>() {
override suspend fun run(params: Params) = try {
repo.duplicate(
command = Command.Duplicate(
context = params.context,
original = params.original
)
).let {
Either.Right(it)
}
} catch (t: Throwable) {
Either.Left(t)
}
/**
* @property context context id
* @property original id of the original block id, which we need to duplicate
*/
data class Params(
val context: Id,
val original: Id
)
}

View file

@ -0,0 +1,37 @@
package com.agileburo.anytype.domain.block.interactor
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 unlinking blocks from its context.
* Unlinking is a remplacement for delete operations.
*/
class UnlinkBlocks(private val repo: BlockRepository) : BaseUseCase<Unit, UnlinkBlocks.Params>() {
override suspend fun run(params: Params) = try {
repo.unlink(
command = Command.Unlink(
context = params.context,
targets = params.targets
)
).let {
Either.Right(it)
}
} catch (t: Throwable) {
Either.Left(t)
}
/**
* Params for unlinking a set of blocks from its context (i.e. page)
* @property context context id
* @property targets ids of the blocks, which we need to unlink from the [context]
*/
data class Params(
val context: Id,
val targets: List<Id>
)
}

View file

@ -1,39 +1,72 @@
package com.agileburo.anytype.domain.block.model
import com.agileburo.anytype.domain.common.Id
sealed class Command {
/**
* @property contextId context id
* @property blockId target block id
* @property text updated text
* @property marks marks of the updated text
*/
class UpdateText(
val contextId: String,
val blockId: String,
val contextId: Id,
val blockId: Id,
val text: String,
val marks: List<Block.Content.Text.Mark>
)
/**
* @property context context id
* @property target id of the target checkbox block
* @property isChecked new checked/unchecked state for this checkbox block
*/
class UpdateCheckbox(
val context: String,
val target: String,
val context: Id,
val target: Id,
val isChecked: Boolean
)
/**
* Params for creating a block
* Command for creating a block
* @property contextId id of the context of the block (i.e. page, dashboard or something else)
* @property targetId id of the block associated with the block we need to create
* @property position position of the block that we need to create in relation with the target block
* @property prototype a prototype of the block we would like to create
*/
class Create(
val contextId: String,
val targetId: String,
val contextId: Id,
val targetId: Id,
val position: Position,
val prototype: Block.Prototype
)
class Dnd(
val contextId: String,
val targetId: String,
val targetContextId: String,
val contextId: Id,
val targetId: Id,
val targetContextId: Id,
val blockIds: List<String>,
val position: Position
)
/**
* Command for block duplication
* @property context context id
* @property original id of the original block, which we need to duplicate
*/
class Duplicate(
val context: Id,
val original: Id
)
/**
* Command for unlinking a set of blocks from its context (i.e. page)
* @property context context id
* @property targets ids of the blocks, which we need to unlink from its [context]
*/
class Unlink(
val context: Id,
val targets: List<Id>
)
}

View file

@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.Flow
interface BlockRepository {
suspend fun dnd(command: Command.Dnd)
suspend fun duplicate(command: Command.Duplicate): Id
suspend fun unlink(command: Command.Unlink)
suspend fun create(command: Command.Create)
suspend fun updateText(command: Command.UpdateText)
suspend fun updateCheckbox(command: Command.UpdateCheckbox)

View file

@ -1,6 +1,7 @@
package com.agileburo.anytype.domain.event.model
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.common.Id
sealed class Event {
@ -15,6 +16,10 @@ sealed class Event {
val blocks: List<Block>
) : Command()
data class DeleteBlock(
val target: Id
) : Command()
data class UpdateBlockText(
val id: String,
val text: String

View file

@ -24,7 +24,8 @@ class BlockMiddleware(
Events.Event.Message.ValueCase.BLOCKSHOW,
Events.Event.Message.ValueCase.BLOCKADD,
Events.Event.Message.ValueCase.BLOCKSETTEXT,
Events.Event.Message.ValueCase.BLOCKSETCHILDRENIDS
Events.Event.Message.ValueCase.BLOCKSETCHILDRENIDS,
Events.Event.Message.ValueCase.BLOCKDELETE
)
private val supportedTextStyles = listOf(
@ -180,6 +181,11 @@ class BlockMiddleware(
text = event.blockSetText.text.value
)
}
Events.Event.Message.ValueCase.BLOCKDELETE -> {
EventEntity.Command.DeleteBlock(
target = event.blockDelete.blockId
)
}
Events.Event.Message.ValueCase.BLOCKSETCHILDRENIDS -> {
EventEntity.Command.UpdateStructure(
id = event.blockSetChildrenIds.id,
@ -348,6 +354,13 @@ class BlockMiddleware(
middleware.dnd(command)
}
override suspend fun duplicate(command: CommandEntity.Duplicate): String =
middleware.duplicate(command)
override suspend fun unlink(command: CommandEntity.Unlink) {
middleware.unlink(command)
}
private fun extractDashboard(block: Models.Block): BlockEntity.Content.Dashboard {
return BlockEntity.Content.Dashboard(
type = when {

View file

@ -45,7 +45,6 @@ public class Middleware {
}
public CreateAccountResponse createAccount(String name, String path) throws Exception {
Account.Create.Request request;
if (path != null) {
@ -178,7 +177,6 @@ public class Middleware {
String text,
List<Models.Block.Content.Text.Mark> marks
) throws Exception {
Timber.d("Updating block with the follwing text:\n%s", text);
Models.Block.Content.Text.Marks markup = Models.Block.Content.Text.Marks
@ -220,7 +218,6 @@ public class Middleware {
PositionEntity position,
BlockEntity.Prototype prototype
) throws Exception {
Models.Block.Content.Text contentModel = null;
if (prototype instanceof BlockEntity.Prototype.Text) {
@ -345,7 +342,6 @@ public class Middleware {
}
public void dnd(CommandEntity.Dnd command) throws Exception {
Models.Block.Position positionModel = null;
switch (command.getPosition()) {
@ -379,4 +375,32 @@ public class Middleware {
service.blockListMove(request);
}
public String duplicate(CommandEntity.Duplicate command) throws Exception {
Block.Duplicate.Request request = Block.Duplicate.Request
.newBuilder()
.setContextId(command.getContext())
.setTargetId(command.getOriginal())
.setBlockId(command.getOriginal())
.setPosition(Models.Block.Position.Bottom)
.build();
Timber.d("Duplicating blocks with the following request:\n%s", request.toString());
Block.Duplicate.Response response = service.blockDuplicate(request);
return response.getBlockId();
}
public void unlink(CommandEntity.Unlink command) throws Exception {
Block.Unlink.Request request = Block.Unlink.Request
.newBuilder()
.setContextId(command.getContext())
.addAllBlockIds(command.getTargets())
.build();
Timber.d("Unlinking blocks with the following request:\n%s", request.toString());
service.blockUnlink(request);
}
}

View file

@ -152,4 +152,26 @@ public class DefaultMiddlewareService implements MiddlewareService {
return response;
}
}
@Override
public Block.Unlink.Response blockUnlink(Block.Unlink.Request request) throws Exception {
byte[] encoded = Lib.blockUnlink(request.toByteArray());
Block.Unlink.Response response = Block.Unlink.Response.parseFrom(encoded);
if (response.getError() != null && response.getError().getCode() != Block.Unlink.Response.Error.Code.NULL) {
throw new Exception(response.getError().getDescription());
} else {
return response;
}
}
@Override
public Block.Duplicate.Response blockDuplicate(Block.Duplicate.Request request) throws Exception {
byte[] encoded = Lib.blockDuplicate(request.toByteArray());
Block.Duplicate.Response response = Block.Duplicate.Response.parseFrom(encoded);
if (response.getError() != null && response.getError().getCode() != Block.Duplicate.Response.Error.Code.NULL) {
throw new Exception(response.getError().getDescription());
} else {
return response;
}
}
}

View file

@ -36,4 +36,8 @@ public interface MiddlewareService {
Block.Set.Text.Checked.Response blockSetTextChecked(Block.Set.Text.Checked.Request request) throws Exception;
BlockList.Move.Response blockListMove(BlockList.Move.Request request) throws Exception;
Block.Unlink.Response blockUnlink(Block.Unlink.Request request) throws Exception;
Block.Duplicate.Response blockDuplicate(Block.Duplicate.Request request) throws Exception;
}

View file

@ -9,12 +9,11 @@ import com.agileburo.anytype.core_ui.state.ControlPanelState.Toolbar
import com.agileburo.anytype.core_utils.common.EventWrapper
import com.agileburo.anytype.core_utils.ext.withLatestFrom
import com.agileburo.anytype.core_utils.ui.ViewStateViewModel
import com.agileburo.anytype.domain.block.interactor.CreateBlock
import com.agileburo.anytype.domain.block.interactor.UpdateBlock
import com.agileburo.anytype.domain.block.interactor.UpdateCheckbox
import com.agileburo.anytype.domain.block.interactor.*
import com.agileburo.anytype.domain.block.model.Block
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.event.interactor.ObserveEvents
import com.agileburo.anytype.domain.event.model.Event
import com.agileburo.anytype.domain.ext.addMark
@ -40,7 +39,9 @@ class PageViewModel(
private val updateBlock: UpdateBlock,
private val createBlock: CreateBlock,
private val observeEvents: ObserveEvents,
private val updateCheckbox: UpdateCheckbox
private val updateCheckbox: UpdateCheckbox,
private val unlinkBlocks: UnlinkBlocks,
private val duplicateBlock: DuplicateBlock
) : ViewStateViewModel<PageViewModel.ViewState>(),
SupportNavigation<EventWrapper<AppNavigation.Command>> {
@ -50,13 +51,13 @@ class PageViewModel(
private val renderingChannel = Channel<List<Block>>()
private val renderings = renderingChannel.consumeAsFlow()
private val focusChannel = ConflatedBroadcastChannel(DEFAULT_FOCUS_ID)
private val focusChannel = ConflatedBroadcastChannel(EMPTY_FOCUS_ID)
private val focusChanges = focusChannel.asFlow()
private val textChannel = Channel<Triple<String, String, List<Block.Content.Text.Mark>>>()
private val textChannel = Channel<Triple<Id, String, List<Block.Content.Text.Mark>>>()
private val textChanges = textChannel.consumeAsFlow()
private val selectionChannel = Channel<Pair<String, IntRange>>()
private val selectionChannel = Channel<Pair<Id, IntRange>>()
private val selectionsChanges = selectionChannel.consumeAsFlow()
private val markupActionChannel = Channel<MarkupAction>()
@ -66,7 +67,12 @@ class PageViewModel(
* Currently opened page id.
*/
private var pageId: String = ""
/**
* Current set of blocks on this page.
*/
var blocks: List<Block> = emptyList()
private set
override val navigation = MutableLiveData<EventWrapper<AppNavigation.Command>>()
@ -86,7 +92,8 @@ class PageViewModel(
private fun startProcessingControlPanelViewState() {
viewModelScope.launch {
controlPanelInteractor.state().collect { controlPanelViewState.postValue(it) }
controlPanelInteractor.state().distinctUntilChanged()
.collect { controlPanelViewState.postValue(it) }
}
}
@ -110,14 +117,22 @@ class PageViewModel(
)
}
is Event.Command.AddBlock -> {
blocks = blocks + event.blocks
viewModelScope.launch {
focusChannel.send(event.blocks.last().id)
renderingChannel.send(blocks)
}
}
is Event.Command.UpdateStructure -> {
blocks = blocks.map { block ->
if (block.id == pageId)
block.copy(
children = block.children + event.blocks.map { it.id }
)
if (block.id == event.id)
block.copy(children = event.children)
else
block
} + event.blocks
}
}
is Event.Command.DeleteBlock -> {
blocks = blocks.filter { it.id != event.target }
viewModelScope.launch { renderingChannel.send(blocks) }
}
}
@ -131,7 +146,6 @@ class PageViewModel(
.filter { (_, selection) -> selection.first != selection.last }
) { a, b -> Pair(a, b) }
.onEach { (action, selection) ->
focusChannel.send(selection.first)
applyMarkup(selection, action)
}
.launchIn(viewModelScope)
@ -212,7 +226,6 @@ class PageViewModel(
textChanges
.debounce(TEXT_CHANGES_DEBOUNCE_DURATION)
.map { (id, text, marks) ->
val update = blocks.map { block ->
if (block.id == id) {
block.copy(
@ -239,9 +252,7 @@ class PageViewModel(
}
private fun proceedWithUpdatingBlock(params: UpdateBlock.Params) {
Timber.d("Starting updating block with params: $params")
updateBlock.invoke(viewModelScope, params) { result ->
result.either(
fnL = { Timber.e(it, "Error while updating text: $params") },
@ -281,6 +292,10 @@ class PageViewModel(
controlPanelInteractor.onEvent(ControlPanelMachine.Event.OnSelectionChanged(selection))
}
fun onBlockFocusChanged(id: String, hasFocus: Boolean) {
if (hasFocus) viewModelScope.launch { focusChannel.send(id) }
}
fun onMarkupActionClicked(markup: Markup.Type) {
viewModelScope.launch {
markupActionChannel.send(MarkupAction(type = markup))
@ -303,6 +318,48 @@ class PageViewModel(
controlPanelInteractor.onEvent(ControlPanelMachine.Event.OnMarkupToolbarColorClicked)
}
fun onActionDeleteClicked() {
viewModelScope.launch {
focusChanges
.take(1)
.collect { focus ->
unlinkBlocks.invoke(
scope = this,
params = UnlinkBlocks.Params(
context = pageId,
targets = listOf(focus)
)
) { result ->
result.either(
fnL = { Timber.e(it, "Error while unlinking block with id: $focus") },
fnR = { Timber.d("Succesfully unlinked block with id: $focus") }
)
}
}
}
}
fun onActionDuplicateClicked() {
viewModelScope.launch {
focusChanges
.take(1)
.collect { focus ->
duplicateBlock.invoke(
scope = this,
params = DuplicateBlock.Params(
context = pageId,
original = focus
)
) { result ->
result.either(
fnL = { Timber.e(it, "Error while duplicating block with id: $focus") },
fnR = { Timber.d("Succesfully duplicated block with id: $focus") }
)
}
}
}
}
fun onAddTextBlockClicked(style: Block.Content.Text.Style) {
controlPanelInteractor.onEvent(ControlPanelMachine.Event.OnOptionSelected)
createBlock.invoke(
@ -341,6 +398,10 @@ class PageViewModel(
controlPanelInteractor.onEvent(ControlPanelMachine.Event.OnAddBlockToolbarClicked)
}
fun onActionToolbarClicked() {
controlPanelInteractor.onEvent(ControlPanelMachine.Event.OnActionToolbarClicked)
}
sealed class ViewState {
object Loading : ViewState()
data class Success(val blocks: List<BlockView>) : ViewState()
@ -348,7 +409,7 @@ class PageViewModel(
}
companion object {
const val DEFAULT_FOCUS_ID = ""
const val EMPTY_FOCUS_ID = ""
const val TEXT_CHANGES_DEBOUNCE_DURATION = 500L
}
@ -411,6 +472,11 @@ class PageViewModel(
* Represents an event when user selected a text color on [Toolbar.Color] toolbar.
*/
object OnTextColorSelected : Event()
/**
* Represents an event when user selected an action toolbar on [Toolbar.Block]
*/
object OnActionToolbarClicked : Event()
}
/**
@ -419,13 +485,35 @@ class PageViewModel(
class Reducer : StateReducer<ControlPanelState, Event> {
override val function: suspend (ControlPanelState, Event) -> ControlPanelState
get() = { state, event -> reduce(state, event) }
get() = { state, event ->
reduce(
state,
event
).also { Timber.d("Reducing event:\n$event") }
}
override suspend fun reduce(state: ControlPanelState, event: Event) = when (event) {
is Event.OnSelectionChanged -> state.copy(
markupToolbar = state.markupToolbar.copy(
isVisible = event.selection.first != event.selection.last
)
isVisible = event.selection.first != event.selection.last,
selectedAction = if (event.selection.first != event.selection.last)
state.markupToolbar.selectedAction
else
null
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
colorToolbar = if (event.selection.first != event.selection.last)
state.colorToolbar.copy()
else
state.colorToolbar.copy(isVisible = false)
)
is Event.OnMarkupToolbarColorClicked -> state.copy(
colorToolbar = state.colorToolbar.copy(
@ -455,6 +543,9 @@ class PageViewModel(
Toolbar.Block.Action.ADD
else
null
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
)
)
is Event.OnOptionSelected -> state.copy(
@ -465,6 +556,22 @@ class PageViewModel(
selectedAction = null
)
)
is Event.OnActionToolbarClicked -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = !state.actionToolbar.isVisible
),
blockToolbar = state.blockToolbar.copy(
selectedAction = if (state.actionToolbar.isVisible)
null
else Toolbar.Block.Action.BLOCK_ACTION
)
)
}
}
}

View file

@ -2,9 +2,7 @@ package com.agileburo.anytype.presentation.page
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.agileburo.anytype.domain.block.interactor.CreateBlock
import com.agileburo.anytype.domain.block.interactor.UpdateBlock
import com.agileburo.anytype.domain.block.interactor.UpdateCheckbox
import com.agileburo.anytype.domain.block.interactor.*
import com.agileburo.anytype.domain.event.interactor.ObserveEvents
import com.agileburo.anytype.domain.page.ClosePage
import com.agileburo.anytype.domain.page.OpenPage
@ -15,7 +13,9 @@ class PageViewModelFactory(
private val updateBlock: UpdateBlock,
private val createBlock: CreateBlock,
private val observeEvents: ObserveEvents,
private val updateCheckbox: UpdateCheckbox
private val updateCheckbox: UpdateCheckbox,
private val unlinkBlocks: UnlinkBlocks,
private val duplicateBlock: DuplicateBlock
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
@ -26,7 +26,9 @@ class PageViewModelFactory(
updateBlock = updateBlock,
createBlock = createBlock,
observeEvents = observeEvents,
updateCheckbox = updateCheckbox
updateCheckbox = updateCheckbox,
duplicateBlock = duplicateBlock,
unlinkBlocks = unlinkBlocks
) as T
}
}

View file

@ -0,0 +1,69 @@
package com.agileburo.anytype.presentation
import MockDataFactory
import com.agileburo.anytype.domain.block.model.Block
object MockBlockFactory {
fun makeOnePageWithOneTextBlock(
root: String,
child: String,
style: Block.Content.Text.Style = Block.Content.Text.Style.P
) = listOf(
Block(
id = root,
fields = Block.Fields(emptyMap()),
content = Block.Content.Page(
style = Block.Content.Page.Style.SET
),
children = listOf(child)
),
Block(
id = child,
fields = Block.Fields(emptyMap()),
content = Block.Content.Text(
text = MockDataFactory.randomString(),
marks = emptyList(),
style = style
),
children = emptyList()
)
)
fun makeOnePageWithTwoTextBlocks(
root: String,
firstChild: String,
firstChildStyle: Block.Content.Text.Style = Block.Content.Text.Style.P,
secondChild: String,
secondChildStyle: Block.Content.Text.Style = Block.Content.Text.Style.P
) = listOf(
Block(
id = root,
fields = Block.Fields(emptyMap()),
content = Block.Content.Page(
style = Block.Content.Page.Style.SET
),
children = listOf(firstChild, secondChild)
),
Block(
id = firstChild,
fields = Block.Fields(emptyMap()),
content = Block.Content.Text(
text = MockDataFactory.randomString(),
marks = emptyList(),
style = firstChildStyle
),
children = emptyList()
),
Block(
id = secondChild,
fields = Block.Fields(emptyMap()),
content = Block.Content.Text(
text = MockDataFactory.randomString(),
marks = emptyList(),
style = secondChildStyle
),
children = emptyList()
)
)
}

View file

@ -5,14 +5,13 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.agileburo.anytype.core_ui.common.Markup
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.domain.base.Either
import com.agileburo.anytype.domain.block.interactor.CreateBlock
import com.agileburo.anytype.domain.block.interactor.UpdateBlock
import com.agileburo.anytype.domain.block.interactor.UpdateCheckbox
import com.agileburo.anytype.domain.block.interactor.*
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.event.interactor.ObserveEvents
import com.agileburo.anytype.domain.event.model.Event
import com.agileburo.anytype.domain.page.ClosePage
import com.agileburo.anytype.domain.page.OpenPage
import com.agileburo.anytype.presentation.MockBlockFactory
import com.agileburo.anytype.presentation.mapper.toView
import com.agileburo.anytype.presentation.navigation.AppNavigation
import com.agileburo.anytype.presentation.page.PageViewModel.ViewState
@ -57,6 +56,12 @@ class PageViewModelTest {
@Mock
lateinit var updateCheckbox: UpdateCheckbox
@Mock
lateinit var duplicateBlock: DuplicateBlock
@Mock
lateinit var unlinkBlocks: UnlinkBlocks
private lateinit var vm: PageViewModel
@Before
@ -302,6 +307,13 @@ class PageViewModelTest {
)
)
delay(100)
emit(
Event.Command.UpdateStructure(
context = root,
id = root,
children = listOf(child, added.id)
)
)
emit(
Event.Command.AddBlock(
blocks = listOf(added)
@ -322,7 +334,7 @@ class PageViewModelTest {
coroutineTestRule.advanceTime(200)
val expected = ViewState.Success(listOf(page.last().toView(), added.toView()))
val expected = ViewState.Success(listOf(page.last().toView(), added.toView(focused = true)))
vm.state.test().assertValue(expected)
}
@ -479,6 +491,11 @@ class PageViewModelTest {
val firstTimeRange = 0..3
val firstTimeMarkup = Markup.Type.BOLD
vm.onBlockFocusChanged(
hasFocus = true,
id = paragraph.id
)
vm.onSelectionChanged(
id = paragraph.id,
selection = firstTimeRange
@ -579,20 +596,18 @@ class PageViewModelTest {
paragraph
)
observeEvents.stub {
onBlocking { build() } doReturn flow {
delay(100)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = blocks
)
val events = flow {
delay(100)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = blocks
)
}
)
}
stubObserveEvents(events)
stubOpenPage()
buildViewModel()
vm.open(root)
@ -602,6 +617,11 @@ class PageViewModelTest {
val firstTimeRange = 0..3
val firstTimeMarkup = Markup.Type.BOLD
vm.onBlockFocusChanged(
hasFocus = true,
id = paragraph.id
)
vm.onSelectionChanged(
id = paragraph.id,
selection = firstTimeRange
@ -707,20 +727,18 @@ class PageViewModelTest {
paragraph
)
observeEvents.stub {
onBlocking { build() } doReturn flow {
delay(100)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = blocks
)
val events = flow {
delay(100)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = blocks
)
}
)
}
stubObserveEvents(events)
stubOpenPage()
buildViewModel()
vm.open(root)
@ -790,20 +808,18 @@ class PageViewModelTest {
paragraph
)
observeEvents.stub {
onBlocking { build() } doReturn flow {
delay(100)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = blocks
)
val events = flow {
delay(100)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = blocks
)
}
)
}
stubObserveEvents(events)
stubOpenPage()
buildViewModel()
val testObserver = vm.state.test()
@ -881,18 +897,17 @@ class PageViewModelTest {
paragraph
)
observeEvents.stub {
onBlocking { build() } doReturn flow {
delay(100)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = blocks
)
val events = flow {
delay(100)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = blocks
)
}
)
}
stubObserveEvents(events)
stubOpenPage()
buildViewModel()
@ -1035,6 +1050,9 @@ class PageViewModelTest {
),
markupToolbar = ControlPanelState.Toolbar.Markup(
isVisible = false
),
actionToolbar = ControlPanelState.Toolbar.BlockAction(
isVisible = false
)
)
@ -1062,6 +1080,9 @@ class PageViewModelTest {
),
markupToolbar = ControlPanelState.Toolbar.Markup(
isVisible = false
),
actionToolbar = ControlPanelState.Toolbar.BlockAction(
isVisible = false
)
)
@ -1093,6 +1114,9 @@ class PageViewModelTest {
),
markupToolbar = ControlPanelState.Toolbar.Markup(
isVisible = expectedVisibility
),
actionToolbar = ControlPanelState.Toolbar.BlockAction(
isVisible = false
)
)
@ -1124,6 +1148,9 @@ class PageViewModelTest {
),
markupToolbar = ControlPanelState.Toolbar.Markup(
isVisible = expectedVisibility
),
actionToolbar = ControlPanelState.Toolbar.BlockAction(
isVisible = false
)
)
@ -1158,6 +1185,9 @@ class PageViewModelTest {
markupToolbar = ControlPanelState.Toolbar.Markup(
isVisible = true,
selectedAction = ControlPanelState.Toolbar.Markup.Action.COLOR
),
actionToolbar = ControlPanelState.Toolbar.BlockAction(
isVisible = false
)
)
@ -1169,33 +1199,7 @@ class PageViewModelTest {
val root = MockDataFactory.randomUuid()
val child = MockDataFactory.randomUuid()
val page = listOf(
Block(
id = root,
fields = Block.Fields(emptyMap()),
content = Block.Content.Page(
style = Block.Content.Page.Style.SET
),
children = listOf(child)
),
Block(
id = child,
fields = Block.Fields(emptyMap()),
content = Block.Content.Text(
text = MockDataFactory.randomString(),
marks = emptyList(),
style = Block.Content.Text.Style.P
),
children = emptyList()
)
)
openPage.stub {
onBlocking { invoke(any(), any(), any()) } doAnswer { answer ->
answer.getArgument<(Either<Throwable, Unit>) -> Unit>(2)(Either.Right(Unit))
}
}
val page = MockBlockFactory.makeOnePageWithOneTextBlock(root = root, child = child)
val style = Block.Content.Text.Style.H1
@ -1219,6 +1223,13 @@ class PageViewModelTest {
)
)
delay(500)
emit(
Event.Command.UpdateStructure(
context = root,
id = root,
children = listOf(child, new.id)
)
)
emit(
Event.Command.AddBlock(
blocks = listOf(new)
@ -1226,15 +1237,8 @@ class PageViewModelTest {
)
}
observeEvents.stub {
onBlocking { build() } doReturn flow
}
openPage.stub {
onBlocking { invoke(any(), any(), any()) } doAnswer { answer ->
answer.getArgument<(Either<Throwable, Unit>) -> Unit>(2)(Either.Right(Unit))
}
}
stubObserveEvents(flow)
stubOpenPage()
buildViewModel()
@ -1268,33 +1272,12 @@ class PageViewModelTest {
val root = MockDataFactory.randomUuid()
val child = MockDataFactory.randomUuid()
val page = listOf(
Block(
id = root,
fields = Block.Fields(emptyMap()),
content = Block.Content.Page(
style = Block.Content.Page.Style.SET
),
children = listOf(child)
),
Block(
id = child,
fields = Block.Fields(emptyMap()),
content = Block.Content.Text(
text = MockDataFactory.randomString(),
marks = emptyList(),
style = Block.Content.Text.Style.CHECKBOX
),
children = emptyList()
)
val page = MockBlockFactory.makeOnePageWithOneTextBlock(
root = root,
child = child,
style = Block.Content.Text.Style.CHECKBOX
)
openPage.stub {
onBlocking { invoke(any(), any(), any()) } doAnswer { answer ->
answer.getArgument<(Either<Throwable, Unit>) -> Unit>(2)(Either.Right(Unit))
}
}
val flow: Flow<Event.Command> = flow {
delay(1000)
emit(
@ -1305,16 +1288,8 @@ class PageViewModelTest {
)
}
observeEvents.stub {
onBlocking { build() } doReturn flow
}
openPage.stub {
onBlocking { invoke(any(), any(), any()) } doAnswer { answer ->
answer.getArgument<(Either<Throwable, Unit>) -> Unit>(2)(Either.Right(Unit))
}
}
stubObserveEvents(flow)
stubOpenPage()
buildViewModel()
vm.open(root)
@ -1336,37 +1311,159 @@ class PageViewModelTest {
)
}
@Test
fun `should start duplicating focused block when requested`() {
val root = MockDataFactory.randomUuid()
val child = MockDataFactory.randomUuid()
val page = MockBlockFactory.makeOnePageWithOneTextBlock(root = root, child = child)
val events: Flow<Event.Command> = flow {
delay(1000)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = page
)
)
}
stubOpenPage()
stubObserveEvents(events)
buildViewModel()
vm.open(root)
coroutineTestRule.advanceTime(1001)
vm.onBlockFocusChanged(id = child, hasFocus = true)
vm.onActionDuplicateClicked()
verify(duplicateBlock, times(1)).invoke(
scope = any(),
params = eq(
DuplicateBlock.Params(
original = child,
context = root
)
),
onResult = any()
)
}
@Test
fun `should start deleting focused block when requested`() {
val root = MockDataFactory.randomUuid()
val child = MockDataFactory.randomUuid()
val page = MockBlockFactory.makeOnePageWithOneTextBlock(root = root, child = child)
val events: Flow<Event.Command> = flow {
delay(1000)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = page
)
)
}
stubOpenPage()
stubObserveEvents(events)
buildViewModel()
vm.open(root)
coroutineTestRule.advanceTime(1001)
vm.onBlockFocusChanged(id = child, hasFocus = true)
vm.onActionDeleteClicked()
verify(unlinkBlocks, times(1)).invoke(
scope = any(),
params = eq(
UnlinkBlocks.Params(
context = root,
targets = listOf(child)
)
),
onResult = any()
)
}
@Test
fun `should delete the first block when the delete-block event received for the first block, then rerender the page`() {
val pageOpenedDelay = 100L
val blockDeletedEventDelay = 100L
val root = MockDataFactory.randomUuid()
val firstChild = MockDataFactory.randomUuid()
val secondChild = MockDataFactory.randomUuid()
val page = MockBlockFactory.makeOnePageWithTwoTextBlocks(
root = root,
firstChild = firstChild,
secondChild = secondChild
)
val events: Flow<Event.Command> = flow {
delay(pageOpenedDelay)
emit(
Event.Command.ShowBlock(
rootId = root,
blocks = page
)
)
delay(blockDeletedEventDelay)
emit(
Event.Command.DeleteBlock(
target = firstChild
)
)
}
stubOpenPage()
stubObserveEvents(events)
buildViewModel()
vm.open(root)
coroutineTestRule.advanceTime(pageOpenedDelay)
val testObserver = vm.state.test()
testObserver.assertValue(
ViewState.Success(
blocks = listOf(
page[1].toView(focused = false),
page.last().toView(focused = false)
)
)
)
vm.onBlockFocusChanged(id = firstChild, hasFocus = true)
vm.onActionDeleteClicked()
assertEquals(expected = 3, actual = vm.blocks.size)
coroutineTestRule.advanceTime(blockDeletedEventDelay)
assertEquals(expected = 2, actual = vm.blocks.size)
testObserver.assertValue(
ViewState.Success(
blocks = listOf(
page.last().toView(focused = false)
)
)
)
}
private fun simulateNormalPageOpeningFlow() {
val root = MockDataFactory.randomUuid()
val child = MockDataFactory.randomUuid()
val page = listOf(
Block(
id = root,
fields = Block.Fields(emptyMap()),
content = Block.Content.Page(
style = Block.Content.Page.Style.SET
),
children = listOf(child)
),
Block(
id = child,
fields = Block.Fields(emptyMap()),
content = Block.Content.Text(
text = MockDataFactory.randomString(),
marks = emptyList(),
style = Block.Content.Text.Style.P
),
children = emptyList()
)
)
openPage.stub {
onBlocking { invoke(any(), any(), any()) } doAnswer { answer ->
answer.getArgument<(Either<Throwable, Unit>) -> Unit>(2)(Either.Right(Unit))
}
}
val page = MockBlockFactory.makeOnePageWithOneTextBlock(root = root, child = child)
val flow: Flow<Event.Command> = flow {
delay(1000)
@ -1378,16 +1475,8 @@ class PageViewModelTest {
)
}
observeEvents.stub {
onBlocking { build() } doReturn flow
}
openPage.stub {
onBlocking { invoke(any(), any(), any()) } doAnswer { answer ->
answer.getArgument<(Either<Throwable, Unit>) -> Unit>(2)(Either.Right(Unit))
}
}
stubObserveEvents(flow)
stubOpenPage()
buildViewModel()
vm.open(root)
@ -1411,9 +1500,9 @@ class PageViewModelTest {
}
}
private fun stubObserveEvents() {
private fun stubObserveEvents(flow: Flow<Event> = flowOf()) {
observeEvents.stub {
onBlocking { build() } doReturn flowOf()
onBlocking { build() } doReturn flow
}
}
@ -1424,7 +1513,9 @@ class PageViewModelTest {
updateBlock = updateBlock,
observeEvents = observeEvents,
createBlock = createBlock,
updateCheckbox = updateCheckbox
updateCheckbox = updateCheckbox,
unlinkBlocks = unlinkBlocks,
duplicateBlock = duplicateBlock
)
}
}