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

Editor | Split the target block on enter-key-pressed event (#230)

This commit is contained in:
Evgenii Kozlov 2020-02-18 22:14:13 +03:00 committed by GitHub
parent 961f135b7f
commit cb46fbe10e
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 777 additions and 390 deletions

View file

@ -1,5 +1,15 @@
# Change log for Android @Anytype app.
## Version 0.0.20 (WIP)
### New features 🚀
* Allow users to split blocks (#229)
### Middleware ⚙️
* Added `blockSplit` command (#229)
## Version 0.0.19
### New features 🚀

View file

@ -13,6 +13,7 @@ import com.agileburo.anytype.R
import com.agileburo.anytype.core_ui.features.page.BlockViewHolder
import com.agileburo.anytype.domain.block.interactor.*
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.event.interactor.InterceptEvents
import com.agileburo.anytype.domain.event.model.Event
import com.agileburo.anytype.domain.page.ClosePage
@ -41,6 +42,7 @@ import com.bartoszlipinski.disableanimationsrule.DisableAnimationsRule
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.stub
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not
@ -92,6 +94,11 @@ class PageFragmentTest {
@Mock
lateinit var mergeBlocks: MergeBlocks
@Mock
lateinit var repo: BlockRepository
private lateinit var splitBlock: SplitBlock
private lateinit var actionToolbar: ViewInteraction
private lateinit var optionToolbar: ViewInteraction
@ -104,20 +111,23 @@ class PageFragmentTest {
actionToolbar = onView(withId(R.id.actionToolbar))
optionToolbar = onView(withId(R.id.optionToolbar))
splitBlock = SplitBlock(repo)
TestPageFragment.testViewModelFactory = PageViewModelFactory(
openPage,
closePage,
updateBlock,
createBlock,
interceptEvents,
updateCheckbox,
unlinkBlocks,
duplicateBlock,
updateTextStyle,
updateTextColor,
updateLinkMarks,
removeLinkMark,
mergeBlocks
openPage = openPage,
closePage = closePage,
updateBlock = updateBlock,
createBlock = createBlock,
interceptEvents = interceptEvents,
updateCheckbox = updateCheckbox,
unlinkBlocks = unlinkBlocks,
duplicateBlock = duplicateBlock,
updateTextStyle = updateTextStyle,
updateTextColor = updateTextColor,
updateLinkMarks = updateLinkMarks,
removeLinkMark = removeLinkMark,
mergeBlocks = mergeBlocks,
splitBlock = splitBlock
)
}
@ -371,10 +381,110 @@ class PageFragmentTest {
onView(allOf(withId(R.id.keyboard), isDisplayed())).perform(click())
onView(withId(R.id.toolbar)).check(matches(not(isDisplayed())))
//onView(withId(R.id.placeholder)).check(matches(hasFocus()))
target.check(matches(not(hasFocus())))
}
@Test
fun shouldSplitBlocks() {
// SETUP
val args = bundleOf(PageFragment.ID_KEY to root)
val delayBeforeGettingEvents = 100L
val delayBeforeSplittingBlocks = 100L
val paragraph = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
children = emptyList(),
content = Block.Content.Text(
text = "FooBar",
marks = emptyList(),
style = Block.Content.Text.Style.P
)
)
val page = listOf(
Block(
id = root,
fields = Block.Fields(emptyMap()),
content = Block.Content.Page(
style = Block.Content.Page.Style.SET
),
children = listOf(paragraph.id)
),
paragraph
)
val new = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
children = emptyList(),
content = Block.Content.Text(
text = "Bar",
marks = emptyList(),
style = Block.Content.Text.Style.P
)
)
stubEvents(
events = flow {
delay(delayBeforeGettingEvents)
emit(
listOf(
Event.Command.ShowBlock(
rootId = root,
blocks = page,
context = root
)
)
)
delay(delayBeforeSplittingBlocks)
emit(
listOf(
Event.Command.GranularChange(
context = root,
id = paragraph.id,
text = "Foo"
),
Event.Command.UpdateStructure(
context = root,
id = page.first().id,
children = listOf(paragraph.id, new.id)
),
Event.Command.AddBlock(
context = root,
blocks = listOf(new)
)
)
)
}
)
launchFragmentInContainer<TestPageFragment>(
fragmentArgs = args,
themeResId = R.style.AppTheme
)
// TESTING
advance(delayBeforeGettingEvents)
val target = onView(withRecyclerView(R.id.recycler).atPositionOnView(0, R.id.textContent))
target.check(matches(withText(paragraph.content.asText().text)))
advance(delayBeforeSplittingBlocks)
onView(withRecyclerView(R.id.recycler).atPositionOnView(0, R.id.textContent)).apply {
check(matches(withText("Foo")))
}
onView(withRecyclerView(R.id.recycler).atPositionOnView(1, R.id.textContent)).apply {
check(matches(withText("Bar")))
}
}
/**
* STUBBING
*/
@ -396,6 +506,12 @@ class PageFragmentTest {
}
}
private fun stubEvents(events: Flow<List<Event>>) {
interceptEvents.stub {
onBlocking { build() } doReturn events
}
}
/**
* Moves coroutines clock time.
*/

View file

@ -45,7 +45,8 @@ class PageModule {
duplicateBlock: DuplicateBlock,
updateTextStyle: UpdateTextStyle,
updateTextColor: UpdateTextColor,
mergeBlocks: MergeBlocks
mergeBlocks: MergeBlocks,
splitBlock: SplitBlock
): PageViewModelFactory = PageViewModelFactory(
openPage = openPage,
closePage = closePage,
@ -59,7 +60,8 @@ class PageModule {
updateTextColor = updateTextColor,
updateLinkMarks = updateLinkMarks,
removeLinkMark = removeLinkMark,
mergeBlocks = mergeBlocks
mergeBlocks = mergeBlocks,
splitBlock = splitBlock
)
@Provides
@ -135,6 +137,14 @@ class PageModule {
repo = repo
)
@Provides
@PerScreen
fun provideSplitBlockUseCase(
repo: BlockRepository
): SplitBlock = SplitBlock(
repo = repo
)
@Provides
@PerScreen
fun provideUpdateLinkMarks(): UpdateLinkMarks = UpdateLinkMarks()

View file

@ -43,7 +43,7 @@ class BlockAdapter(
private val onFocusChanged: (String, Boolean) -> Unit,
private val onEmptyBlockBackspaceClicked: (String) -> Unit,
private val onNonEmptyBlockBackspaceClicked: (String) -> Unit,
private val onSplitLineEnterClicked: (String) -> Unit,
private val onSplitLineEnterClicked: (String, Int) -> Unit,
private val onEndLineEnterClicked: (String, Editable) -> Unit,
private val onFooterClicked: () -> Unit
) : RecyclerView.Adapter<BlockViewHolder>() {
@ -413,8 +413,8 @@ class BlockAdapter(
onEndLineEnterClicked = { editable ->
onEndLineEnterClicked(blocks[holder.adapterPosition].id, editable)
},
onSplitLineEnterClicked = {
onSplitLineEnterClicked(blocks[holder.adapterPosition].id)
onSplitLineEnterClicked = { index ->
onSplitLineEnterClicked(blocks[holder.adapterPosition].id, index)
}
)
holder.enableBackspaceDetector(

View file

@ -70,7 +70,12 @@ class BlockViewDiffUtil(
*/
data class Payload(
val changes: List<Int>
)
) {
fun markupChanged() = changes.contains(MARKUP_CHANGED)
fun textChanged() = changes.contains(TEXT_CHANGED)
fun textColorChanged() = changes.contains(TEXT_COLOR_CHANGED)
fun focusChanged() = changes.contains(FOCUS_CHANGED)
}
companion object {
const val TEXT_CHANGED = 0

View file

@ -9,11 +9,8 @@ import com.agileburo.anytype.core_ui.R
import com.agileburo.anytype.core_ui.common.*
import com.agileburo.anytype.core_ui.extensions.color
import com.agileburo.anytype.core_ui.extensions.tint
import com.agileburo.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.FOCUS_CHANGED
import com.agileburo.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.MARKUP_CHANGED
import com.agileburo.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.NUMBER_CHANGED
import com.agileburo.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.TEXT_CHANGED
import com.agileburo.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.TEXT_COLOR_CHANGED
import com.agileburo.anytype.core_ui.features.page.BlockViewDiffUtil.Payload
import com.agileburo.anytype.core_ui.tools.DefaultSpannableFactory
import com.agileburo.anytype.core_ui.tools.DefaultTextWatcher
@ -58,7 +55,7 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun enableEnterKeyDetector(
onEndLineEnterClicked: (Editable) -> Unit,
onSplitLineEnterClicked: () -> Unit
onSplitLineEnterClicked: (Int) -> Unit
) {
content.filters = arrayOf(
DefaultEnterKeyDetector(
@ -136,29 +133,27 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
Timber.d("Processing $payload for new view:\n$item")
if (item is BlockView.Text) {
if (payload.changes.contains(TEXT_CHANGED))
if (content.text.toString() != item.text) {
Timber.d("Text changed.\nBefore:${content.text.toString()}\nAfter:${item.text}")
content.pauseTextWatchers {
if (item is Markup)
content.setText(item.toSpannable(), BufferType.SPANNABLE)
}
if (payload.textChanged()) {
val cursor = content.selectionEnd
content.pauseTextWatchers {
if (item is Markup)
content.setText(item.toSpannable(), BufferType.SPANNABLE)
else
content.setText(item.text)
}
content.setSelection(cursor)
} else if (payload.markupChanged()) {
if (item is Markup) setMarkup(item)
}
if (payload.changes.contains(TEXT_COLOR_CHANGED))
if (payload.textColorChanged()) {
item.color?.let { setTextColor(it) }
}
if (item is Markup) {
if (payload.changes.contains(MARKUP_CHANGED) && !payload.changes.contains(
TEXT_CHANGED
)
)
setMarkup(item)
}
}
if (item is Focusable) {
if (payload.changes.contains(FOCUS_CHANGED))
if (payload.focusChanged())
setFocus(item)
}
}

View file

@ -710,6 +710,60 @@ class BlockAdapterTest {
)
}
@Test
fun `should preserve cursor position after updating paragraph text`() {
// Setup
val title = BlockView.Paragraph(
text = MockDataFactory.randomString(),
id = MockDataFactory.randomUuid()
)
val updated = title.copy(
text = MockDataFactory.randomString()
)
val views = listOf(title)
val adapter = buildAdapter(views)
val recycler = RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context)
}
val holder = adapter.onCreateViewHolder(recycler, BlockViewHolder.HOLDER_PARAGRAPH)
adapter.onBindViewHolder(holder, 0)
check(holder is BlockViewHolder.Paragraph)
// Testing
assertEquals(
expected = title.text,
actual = holder.content.text.toString()
)
val cursorBeforeUpdate = holder.content.selectionEnd
holder.processChangePayload(
item = updated,
payloads = listOf(
BlockViewDiffUtil.Payload(
changes = listOf(TEXT_CHANGED)
)
)
)
val cursorAfterUpdate = holder.content.selectionEnd
assertEquals(
expected = cursorBeforeUpdate,
actual = cursorAfterUpdate
)
}
private fun buildAdapter(
views: List<BlockView>,
onFocusChanged: (String, Boolean) -> Unit = { _, _ -> },
@ -719,7 +773,7 @@ class BlockAdapterTest {
blocks = views,
onNonEmptyBlockBackspaceClicked = {},
onEmptyBlockBackspaceClicked = {},
onSplitLineEnterClicked = {},
onSplitLineEnterClicked = { _, _ -> },
onEndLineEnterClicked = { _, _ -> },
onTextChanged = onTextChanged,
onCheckboxClicked = {},

View file

@ -5,15 +5,16 @@ import android.text.Spanned
class DefaultEnterKeyDetector(
private val onEndLineEnterClicked: () -> Unit,
private val onSplitLineEnterClicked: () -> Unit
private val onSplitLineEnterClicked: (Int) -> Unit
) : EnterKeyDetector() {
override fun onEndEnterPress(textBeforeEnter: Spanned): CharSequence? {
onEndLineEnterClicked()
return EMPTY_REPLACEMENT
}
override fun onSplitEnterPress(textBeforeEnter: Spanned): CharSequence? {
onSplitLineEnterClicked()
override fun onSplitEnterPress(textBeforeEnter: Spanned, index: Int): CharSequence? {
onSplitLineEnterClicked(index)
return EMPTY_REPLACEMENT
}
}
@ -34,14 +35,14 @@ abstract class EnterKeyDetector : InputFilter {
if (dend - 1 == dest.lastIndex)
onEndEnterPress(textBeforeEnter = dest)
else
onSplitEnterPress(textBeforeEnter = dest)
onSplitEnterPress(textBeforeEnter = dest, index = dend)
}
else -> null
}
}
abstract fun onEndEnterPress(textBeforeEnter: Spanned): CharSequence?
abstract fun onSplitEnterPress(textBeforeEnter: Spanned): CharSequence?
abstract fun onSplitEnterPress(textBeforeEnter: Spanned, index: Int): CharSequence?
companion object {
const val EMPTY_REPLACEMENT = ""

View file

@ -266,6 +266,12 @@ fun Command.Merge.toEntity(): CommandEntity.Merge = CommandEntity.Merge(
pair = pair
)
fun Command.Split.toEntity(): CommandEntity.Split = CommandEntity.Split(
context = context,
target = target,
index = index
)
fun Position.toEntity(): PositionEntity {
return PositionEntity.valueOf(name)
}

View file

@ -59,4 +59,10 @@ class CommandEntity {
val context: String,
val pair: Pair<String, String>
)
data class Split(
val context: String,
val target: String,
val index: Int
)
}

View file

@ -63,4 +63,8 @@ class BlockDataRepository(
override suspend fun merge(command: Command.Merge) {
factory.remote.merge(command.toEntity())
}
override suspend fun split(command: Command.Split) {
factory.remote.split(command.toEntity())
}
}

View file

@ -13,6 +13,7 @@ interface BlockDataStore {
suspend fun dnd(command: CommandEntity.Dnd)
suspend fun duplicate(command: CommandEntity.Duplicate): Id
suspend fun merge(command: CommandEntity.Merge)
suspend fun split(command: CommandEntity.Split)
suspend fun unlink(command: CommandEntity.Unlink)
suspend fun getConfig(): ConfigEntity
suspend fun createPage(parentId: String): String

View file

@ -12,6 +12,7 @@ interface BlockRemote {
suspend fun updateCheckbox(command: CommandEntity.UpdateCheckbox)
suspend fun dnd(command: CommandEntity.Dnd)
suspend fun merge(command: CommandEntity.Merge)
suspend fun split(command: CommandEntity.Split)
suspend fun duplicate(command: CommandEntity.Duplicate): Id
suspend fun unlink(command: CommandEntity.Unlink)
suspend fun getConfig(): ConfigEntity

View file

@ -56,4 +56,8 @@ class BlockRemoteDataStore(private val remote: BlockRemote) : BlockDataStore {
override suspend fun merge(command: CommandEntity.Merge) {
remote.merge(command)
}
override suspend fun split(command: CommandEntity.Split) {
remote.split(command)
}
}

View file

@ -0,0 +1,39 @@
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 splitting the target block into two blocks based on cursor position.
*/
class SplitBlock(private val repo: BlockRepository) : BaseUseCase<Unit, SplitBlock.Params>() {
override suspend fun run(params: Params) = try {
repo.split(
command = Command.Split(
context = params.context,
target = params.target,
index = params.index
)
).let {
Either.Right(it)
}
} catch (t: Throwable) {
Either.Left(t)
}
/**
* Params for splitting one block into two blocks
* @property context context id
* @property target id of the target block, which we need to split
* @property index index or cursor position
*/
data class Params(
val context: Id,
val target: Id,
val index: Int
)
}

View file

@ -103,4 +103,16 @@ sealed class Command {
val context: Id,
val pair: Pair<Id, Id>
)
/**
* Command for splitting one block into two blocks
* @property context context id
* @property target id of the target block, which we need to split
* @property index index or cursor position
*/
data class Split(
val context: Id,
val target: Id,
val index: Int
)
}

View file

@ -10,6 +10,7 @@ interface BlockRepository {
suspend fun unlink(command: Command.Unlink)
suspend fun create(command: Command.Create)
suspend fun merge(command: Command.Merge)
suspend fun split(command: Command.Split)
suspend fun updateText(command: Command.UpdateText)
suspend fun updateTextStyle(command: Command.UpdateStyle)
suspend fun updateTextColor(command: Command.UpdateTextColor)

View file

@ -82,4 +82,8 @@ class BlockMiddleware(
override suspend fun merge(command: CommandEntity.Merge) {
middleware.merge(command)
}
override suspend fun split(command: CommandEntity.Split) {
middleware.split(command)
}
}

View file

@ -502,4 +502,17 @@ public class Middleware {
service.blockMerge(request);
}
public void split(CommandEntity.Split command) throws Exception {
Block.Split.Request request = Block.Split.Request
.newBuilder()
.setBlockId(command.getTarget())
.setContextId(command.getContext())
.setCursorPosition(command.getIndex())
.build();
Timber.d("Splitting the target block with the following request:\n%s", request.toString());
service.blockSplit(request);
}
}

View file

@ -219,6 +219,17 @@ public class DefaultMiddlewareService implements MiddlewareService {
}
}
@Override
public Block.Split.Response blockSplit(Block.Split.Request request) throws Exception {
byte[] encoded = Lib.blockSplit(request.toByteArray());
Block.Split.Response response = Block.Split.Response.parseFrom(encoded);
if (response.getError() != null && response.getError().getCode() != Block.Split.Response.Error.Code.NULL) {
throw new Exception(response.getError().getDescription());
} else {
return response;
}
}
@Override
public BlockList.Duplicate.Response blockListDuplicate(BlockList.Duplicate.Request request) throws Exception {
byte[] encoded = Lib.blockListDuplicate(request.toByteArray());

View file

@ -49,5 +49,7 @@ public interface MiddlewareService {
Block.Merge.Response blockMerge(Block.Merge.Request request) throws Exception;
Block.Split.Response blockSplit(Block.Split.Request request) throws Exception;
BlockList.Duplicate.Response blockListDuplicate(BlockList.Duplicate.Request request) throws Exception;
}

View file

@ -0,0 +1,344 @@
package com.agileburo.anytype.presentation.page
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.core_ui.state.ControlPanelState.Toolbar
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.presentation.common.StateReducer
import com.agileburo.anytype.presentation.page.ControlPanelMachine.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* State machine for control panels consisting of [Interactor], [ControlPanelState], [Event] and [Reducer]
* [Interactor] reduces [Event] to the immutable [ControlPanelState] by applying [Reducer] fuction.
* This [ControlPanelState] then will be rendered.
*/
sealed class ControlPanelMachine {
/**
* @property scope coroutine scope (state machine runs inside this scope)
*/
class Interactor(
private val scope: CoroutineScope
) : ControlPanelMachine() {
private val reducer: Reducer = Reducer()
val channel: Channel<Event> = Channel()
private val events: Flow<Event> = channel.consumeAsFlow()
fun onEvent(event: Event) = scope.launch { channel.send(event) }
/**
* @return a stream of immutable states, as processed by [Reducer].
*/
fun state(): Flow<ControlPanelState> =
events.scan(ControlPanelState.init(), reducer.function)
}
/**
* Represents events related to this state machine and to control panel logics.
*/
sealed class Event {
/**
* Represents text selection changes events
* @property selection text selection (end index and start index are inclusive)
*/
data class OnSelectionChanged(
val selection: IntRange
) : Event()
/**
* Represents an event when user selected a color option on [Toolbar.Markup] toolbar.
*/
object OnMarkupToolbarColorClicked : Event()
/**
* Represents an event when user toggled [Toolbar.AddBlock] toolbar button on [Toolbar.Block].
*/
object OnAddBlockToolbarToggleClicked : Event()
/**
* Represents an event when user toggled [Toolbar.TurnInto] toolbar button on [Toolbar.Block]
*/
object OnTurnIntoToolbarToggleClicked : Event()
/**
* Represents an event when user selected any of the options on [Toolbar.AddBlock] toolbar.
*/
object OnAddBlockToolbarOptionSelected : Event()
/**
* Represents an event when user selected any of the options on [Toolbar.TurnInto] toolbar.
*/
object OnTurnIntoToolbarOptionSelected : Event()
/**
* Represents an event when user toggled [Toolbar.Color] toolbar button on [Toolbar.Block]
*/
object OnColorToolbarToggleClicked : Event()
/**
* Represents an event when user selected a markup text color on [Toolbar.Color] toolbar.
*/
object OnMarkupTextColorSelected : Event()
/**
* Represents an event when user selected a background color on [Toolbar.Color] toolbar.
*/
object OnMarkupBackgroundColorSelected : Event()
/**
* Represents an event when user selected a block text color on [Toolbar.Color] toolbar.
*/
object OnBlockTextColorSelected : Event()
/**
* Represents an event when user selected an action toolbar on [Toolbar.Block]
*/
object OnActionToolbarClicked : Event()
/**
* Represents an event when user cleares the current focus by closing keyboard.
*/
object OnClearFocusClicked : Event()
/**
* Represents an event when focus changes.
* @property id id of the focused block
*/
data class OnFocusChanged(
val id: String,
val style: Block.Content.Text.Style
) : Event()
}
/**
* Concrete reducer implementation that holds all the logic related to control panels.
*/
class Reducer : StateReducer<ControlPanelState, Event> {
override val function: suspend (ControlPanelState, Event) -> ControlPanelState
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,
selectedAction = if (event.selection.first != event.selection.last)
state.markupToolbar.selectedAction
else
null
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null,
isVisible = (event.selection.first == event.selection.last)
),
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(
isVisible = !state.colorToolbar.isVisible
),
markupToolbar = state.markupToolbar.copy(
selectedAction = if (!state.colorToolbar.isVisible)
Toolbar.Markup.Action.COLOR
else
null
)
)
is Event.OnMarkupTextColorSelected -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
markupToolbar = state.markupToolbar.copy(
selectedAction = null
)
)
is Event.OnBlockTextColorSelected -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null
)
)
is Event.OnAddBlockToolbarToggleClicked -> state.copy(
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = !state.addBlockToolbar.isVisible
),
blockToolbar = state.blockToolbar.copy(
selectedAction = if (!state.addBlockToolbar.isVisible)
Toolbar.Block.Action.ADD
else
null
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
colorToolbar = state.colorToolbar.copy(
isVisible = false
)
)
is Event.OnTurnIntoToolbarToggleClicked -> state.copy(
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = !state.turnIntoToolbar.isVisible
),
blockToolbar = state.blockToolbar.copy(
selectedAction = if (!state.turnIntoToolbar.isVisible)
Toolbar.Block.Action.TURN_INTO
else
null
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
colorToolbar = state.colorToolbar.copy(
isVisible = false
)
)
is Event.OnColorToolbarToggleClicked -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = !state.colorToolbar.isVisible
),
blockToolbar = state.blockToolbar.copy(
selectedAction = if (!state.colorToolbar.isVisible)
Toolbar.Block.Action.COLOR
else
null
),
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
)
)
is Event.OnAddBlockToolbarOptionSelected -> state.copy(
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null
)
)
is Event.OnMarkupBackgroundColorSelected -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
markupToolbar = state.markupToolbar.copy(
selectedAction = null
)
)
is Event.OnTurnIntoToolbarOptionSelected -> state.copy(
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null
)
)
is Event.OnActionToolbarClicked -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
turnIntoToolbar = state.turnIntoToolbar.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
)
)
is Event.OnClearFocusClicked -> ControlPanelState.init()
is Event.OnFocusChanged -> {
if (state.isNotVisible())
state.copy(
blockToolbar = state.blockToolbar.copy(
isVisible = true,
selectedAction = null
),
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
focus = ControlPanelState.Focus(
id = event.id,
type = ControlPanelState.Focus.Type.valueOf(
value = event.style.name
)
)
)
else {
state.copy(
blockToolbar = state.blockToolbar.copy(
selectedAction = null
),
focus = ControlPanelState.Focus(
id = event.id,
type = ControlPanelState.Focus.Type.valueOf(
value = event.style.name
)
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
colorToolbar = state.colorToolbar.copy(
isVisible = false
)
)
}
}
}
}
}

View file

@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import com.agileburo.anytype.core_ui.common.Markup
import com.agileburo.anytype.core_ui.features.page.BlockView
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.core_ui.state.ControlPanelState.Toolbar
import com.agileburo.anytype.core_utils.common.EventWrapper
import com.agileburo.anytype.core_utils.ext.replace
import com.agileburo.anytype.core_utils.ext.withLatestFrom
@ -22,12 +21,10 @@ import com.agileburo.anytype.domain.event.model.Event
import com.agileburo.anytype.domain.ext.*
import com.agileburo.anytype.domain.page.ClosePage
import com.agileburo.anytype.domain.page.OpenPage
import com.agileburo.anytype.presentation.common.StateReducer
import com.agileburo.anytype.presentation.mapper.toView
import com.agileburo.anytype.presentation.navigation.AppNavigation
import com.agileburo.anytype.presentation.navigation.SupportNavigation
import com.agileburo.anytype.presentation.page.PageViewModel.ControlPanelMachine.*
import kotlinx.coroutines.CoroutineScope
import com.agileburo.anytype.presentation.page.ControlPanelMachine.Interactor
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.*
@ -47,7 +44,8 @@ class PageViewModel(
private val updateTextColor: UpdateTextColor,
private val updateLinkMarks: UpdateLinkMarks,
private val removeLinkMark: RemoveLinkMark,
private val mergeBlocks: MergeBlocks
private val mergeBlocks: MergeBlocks,
private val splitBlock: SplitBlock
) : ViewStateViewModel<PageViewModel.ViewState>(),
SupportNavigation<EventWrapper<AppNavigation.Command>> {
@ -466,8 +464,23 @@ class PageViewModel(
}
}
fun onSplitLineEnterClicked(id: String) {
// TODO
fun onSplitLineEnterClicked(
id: String,
index: Int
) {
splitBlock.invoke(
scope = viewModelScope,
params = SplitBlock.Params(
context = pageId,
target = id,
index = index
)
) { result ->
result.either(
fnL = { Timber.e(it, "Error while splitting the target block with id: $id") },
fnR = { Timber.d("Succesfully split the target block with id: $id") }
)
}
}
fun onEndLineEnterClicked(
@ -769,336 +782,6 @@ class PageViewModel(
const val TEXT_CHANGES_DEBOUNCE_DURATION = 500L
}
/**
* State machine for control panels consisting of [Interactor], [ControlPanelState], [Event] and [Reducer]
* [Interactor] reduces [Event] to the immutable [ControlPanelState] by applying [Reducer] fuction.
* This [ControlPanelState] then will be rendered.
*/
sealed class ControlPanelMachine {
/**
* @property scope coroutine scope (state machine runs inside this scope)
*/
class Interactor(
private val scope: CoroutineScope
) : ControlPanelMachine() {
private val reducer: Reducer = Reducer()
val channel: Channel<Event> = Channel()
private val events: Flow<Event> = channel.consumeAsFlow()
fun onEvent(event: Event) = scope.launch { channel.send(event) }
/**
* @return a stream of immutable states, as processed by [Reducer].
*/
fun state(): Flow<ControlPanelState> =
events.scan(ControlPanelState.init(), reducer.function)
}
/**
* Represents events related to this state machine and to control panel logics.
*/
sealed class Event {
/**
* Represents text selection changes events
* @property selection text selection (end index and start index are inclusive)
*/
data class OnSelectionChanged(
val selection: IntRange
) : Event()
/**
* Represents an event when user selected a color option on [Toolbar.Markup] toolbar.
*/
object OnMarkupToolbarColorClicked : Event()
/**
* Represents an event when user toggled [Toolbar.AddBlock] toolbar button on [Toolbar.Block].
*/
object OnAddBlockToolbarToggleClicked : Event()
/**
* Represents an event when user toggled [Toolbar.TurnInto] toolbar button on [Toolbar.Block]
*/
object OnTurnIntoToolbarToggleClicked : Event()
/**
* Represents an event when user selected any of the options on [Toolbar.AddBlock] toolbar.
*/
object OnAddBlockToolbarOptionSelected : Event()
/**
* Represents an event when user selected any of the options on [Toolbar.TurnInto] toolbar.
*/
object OnTurnIntoToolbarOptionSelected : Event()
/**
* Represents an event when user toggled [Toolbar.Color] toolbar button on [Toolbar.Block]
*/
object OnColorToolbarToggleClicked : Event()
/**
* Represents an event when user selected a markup text color on [Toolbar.Color] toolbar.
*/
object OnMarkupTextColorSelected : Event()
/**
* Represents an event when user selected a background color on [Toolbar.Color] toolbar.
*/
object OnMarkupBackgroundColorSelected : Event()
/**
* Represents an event when user selected a block text color on [Toolbar.Color] toolbar.
*/
object OnBlockTextColorSelected : Event()
/**
* Represents an event when user selected an action toolbar on [Toolbar.Block]
*/
object OnActionToolbarClicked : Event()
/**
* Represents an event when user cleares the current focus by closing keyboard.
*/
object OnClearFocusClicked : Event()
/**
* Represents an event when focus changes.
* @property id id of the focused block
*/
data class OnFocusChanged(
val id: String,
val style: Block.Content.Text.Style
) : Event()
}
/**
* Concrete reducer implementation that holds all the logic related to control panels.
*/
class Reducer : StateReducer<ControlPanelState, Event> {
override val function: suspend (ControlPanelState, Event) -> ControlPanelState
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,
selectedAction = if (event.selection.first != event.selection.last)
state.markupToolbar.selectedAction
else
null
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null,
isVisible = (event.selection.first == event.selection.last)
),
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(
isVisible = !state.colorToolbar.isVisible
),
markupToolbar = state.markupToolbar.copy(
selectedAction = if (!state.colorToolbar.isVisible)
Toolbar.Markup.Action.COLOR
else
null
)
)
is Event.OnMarkupTextColorSelected -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
markupToolbar = state.markupToolbar.copy(
selectedAction = null
)
)
is Event.OnBlockTextColorSelected -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null
)
)
is Event.OnAddBlockToolbarToggleClicked -> state.copy(
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = !state.addBlockToolbar.isVisible
),
blockToolbar = state.blockToolbar.copy(
selectedAction = if (!state.addBlockToolbar.isVisible)
Toolbar.Block.Action.ADD
else
null
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
colorToolbar = state.colorToolbar.copy(
isVisible = false
)
)
is Event.OnTurnIntoToolbarToggleClicked -> state.copy(
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = !state.turnIntoToolbar.isVisible
),
blockToolbar = state.blockToolbar.copy(
selectedAction = if (!state.turnIntoToolbar.isVisible)
Toolbar.Block.Action.TURN_INTO
else
null
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
colorToolbar = state.colorToolbar.copy(
isVisible = false
)
)
is Event.OnColorToolbarToggleClicked -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = !state.colorToolbar.isVisible
),
blockToolbar = state.blockToolbar.copy(
selectedAction = if (!state.colorToolbar.isVisible)
Toolbar.Block.Action.COLOR
else
null
),
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
)
)
is Event.OnAddBlockToolbarOptionSelected -> state.copy(
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null
)
)
is Event.OnMarkupBackgroundColorSelected -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
markupToolbar = state.markupToolbar.copy(
selectedAction = null
)
)
is Event.OnTurnIntoToolbarOptionSelected -> state.copy(
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
blockToolbar = state.blockToolbar.copy(
selectedAction = null
)
)
is Event.OnActionToolbarClicked -> state.copy(
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
turnIntoToolbar = state.turnIntoToolbar.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
)
)
is Event.OnClearFocusClicked -> ControlPanelState.init()
is Event.OnFocusChanged -> {
if (state.isNotVisible())
state.copy(
blockToolbar = state.blockToolbar.copy(
isVisible = true,
selectedAction = null
),
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
colorToolbar = state.colorToolbar.copy(
isVisible = false
),
focus = ControlPanelState.Focus(
id = event.id,
type = ControlPanelState.Focus.Type.valueOf(
value = event.style.name
)
)
)
else {
state.copy(
blockToolbar = state.blockToolbar.copy(
selectedAction = null
),
focus = ControlPanelState.Focus(
id = event.id,
type = ControlPanelState.Focus.Type.valueOf(
value = event.style.name
)
),
addBlockToolbar = state.addBlockToolbar.copy(
isVisible = false
),
actionToolbar = state.actionToolbar.copy(
isVisible = false
),
turnIntoToolbar = state.turnIntoToolbar.copy(
isVisible = false
),
colorToolbar = state.colorToolbar.copy(
isVisible = false
)
)
}
}
}
}
}
data class MarkupAction(
val type: Markup.Type,
val param: Any? = null
@ -1113,5 +796,4 @@ class PageViewModel(
markupActionChannel.cancel()
controlPanelInteractor.channel.cancel()
}
}
}

View file

@ -20,7 +20,8 @@ open class PageViewModelFactory(
private val updateTextColor: UpdateTextColor,
private val updateLinkMarks: UpdateLinkMarks,
private val removeLinkMark: RemoveLinkMark,
private val mergeBlocks: MergeBlocks
private val mergeBlocks: MergeBlocks,
private val splitBlock: SplitBlock
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
@ -38,7 +39,8 @@ open class PageViewModelFactory(
updateTextColor = updateTextColor,
updateLinkMarks = updateLinkMarks,
removeLinkMark = removeLinkMark,
mergeBlocks = mergeBlocks
mergeBlocks = mergeBlocks,
splitBlock = splitBlock
) as T
}
}

View file

@ -3,7 +3,6 @@ package com.agileburo.anytype.presentation.page
import MockDataFactory
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.presentation.page.PageViewModel.ControlPanelMachine
import com.agileburo.anytype.presentation.util.CoroutinesTestRule
import kotlinx.coroutines.runBlocking
import org.junit.Rule

View file

@ -80,6 +80,9 @@ class PageViewModelTest {
@Mock
lateinit var mergeBlocks: MergeBlocks
@Mock
lateinit var splitBlock: SplitBlock
private lateinit var vm: PageViewModel
@Before
@ -2496,6 +2499,67 @@ class PageViewModelTest {
)
}
@Test
fun `should proceed with splittig block on split-enter-key event`() {
// SETUP
val root = MockDataFactory.randomUuid()
val child = MockDataFactory.randomUuid()
val page = MockBlockFactory.makeOnePageWithOneTextBlock(
root = root,
child = child
)
val flow: Flow<List<Event.Command>> = flow {
delay(100)
emit(
listOf(
Event.Command.ShowBlock(
rootId = root,
blocks = page,
context = root
)
)
)
}
stubObserveEvents(flow)
stubOpenPage()
buildViewModel()
vm.open(root)
coroutineTestRule.advanceTime(100)
// TESTING
vm.onBlockFocusChanged(
id = child,
hasFocus = true
)
val index = MockDataFactory.randomInt()
vm.onSplitLineEnterClicked(
id = child,
index = index
)
verify(splitBlock, times(1)).invoke(
scope = any(),
params = eq(
SplitBlock.Params(
context = root,
target = child,
index = index
)
),
onResult = any()
)
}
private fun simulateNormalPageOpeningFlow() {
val root = MockDataFactory.randomUuid()
@ -2560,7 +2624,8 @@ class PageViewModelTest {
updateTextColor = updateTextColor,
updateLinkMarks = updateLinkMark,
removeLinkMark = removeLinkMark,
mergeBlocks = mergeBlocks
mergeBlocks = mergeBlocks,
splitBlock = splitBlock
)
}
}