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

(ISSUE-112) Link/unlink text (#158)

* IS-112 update package

* IS-112 add base classes

* IS-112 add link design

* IS-112 add link, navigation

* IS-112 add link, di

* IS-112 add link as mark type

* IS-112 link di

* IS-112, link navigation

* IS-112, add link click to page

* IS-112, add ext funcs + tests

* IS-112, link, view + viewmodel

* IS-112 add link span

* IS-112 send url link to page + send to middleware

* IS-112 update navigation with Link model

* IS-112 share link view model between page and link fragment

* IS-112 add linkfy to edit widget

* IS-112 map to link on click

* IS-112 use parcelize in module

* IS-112 add block markup extension funcs + tests

* IS-112 send url from link markup

* IS-112 remove link model

* IS-112 use link view model as shared state

* IS-112 add unlink usecase

* IS-112 update link di

* IS-112 add update link marks use case + tests

* IS-112 add can unlink text use case + tests

* IS-112 use listener for fragment navigation

* IS-112 link view model logic update

* IS-112 page view model update

* IS-112 add remove link mark use case + tests

* IS-112 update di

* IS-112 add unlink logic + fix link

* IS-112 fix after merge

* IS-112 rerender blocks after link/unlink

* IS-112 fix range

* IS-112 fix link

* IS-112 remove link from nav graph

* IS-112 code style fixes

* IS-112 code style fixes

* IS-112 after PR fixes
This commit is contained in:
Konstantin Ivanov 2020-01-29 19:10:55 +03:00 committed by GitHub
parent a4e7f5480b
commit ac0d64f8ab
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1510 additions and 41 deletions

View file

@ -108,6 +108,13 @@ class ComponentManager(private val main: MainComponent) {
.build()
}
val linkAddComponent = Component {
main
.linkAddComponentBuilder()
.linkModule(LinkModule())
.build()
}
class Component<T>(private val builder: () -> T) {
private var instance: T? = null

View file

@ -0,0 +1,38 @@
package com.agileburo.anytype.di.feature
import com.agileburo.anytype.core_utils.di.scope.PerScreen
import com.agileburo.anytype.domain.page.CheckForUnlink
import com.agileburo.anytype.presentation.page.LinkAddViewModelFactory
import com.agileburo.anytype.ui.page.modals.LinkFragment
import dagger.Module
import dagger.Provides
import dagger.Subcomponent
@Subcomponent(modules = [LinkModule::class])
@PerScreen
interface LinkSubComponent {
@Subcomponent.Builder
interface Builder {
fun linkModule(module: LinkModule): Builder
fun build(): LinkSubComponent
}
fun inject(fragment: LinkFragment)
}
@Module
class LinkModule {
@PerScreen
@Provides
fun provideCanUnlink(): CheckForUnlink = CheckForUnlink()
@PerScreen
@Provides
fun provideFactory(
checkForUnlink: CheckForUnlink
): LinkAddViewModelFactory = LinkAddViewModelFactory(
unlink = checkForUnlink
)
}

View file

@ -41,6 +41,8 @@ class PageModule {
interceptEvents: InterceptEvents,
updateCheckbox: UpdateCheckbox,
unlinkBlocks: UnlinkBlocks,
updateLinkMarks: UpdateLinkMarks,
removeLinkMark: RemoveLinkMark,
duplicateBlock: DuplicateBlock,
updateTextStyle: UpdateTextStyle,
updateTextColor: UpdateTextColor
@ -54,7 +56,9 @@ class PageModule {
unlinkBlocks = unlinkBlocks,
duplicateBlock = duplicateBlock,
updateTextStyle = updateTextStyle,
updateTextColor = updateTextColor
updateTextColor = updateTextColor,
updateLinkMarks = updateLinkMarks,
removeLinkMark = removeLinkMark
)
@Provides
@ -130,6 +134,14 @@ class PageModule {
repo = repo
)
@Provides
@PerScreen
fun provideUpdateLinkMarks(): UpdateLinkMarks = UpdateLinkMarks()
@Provides
@PerScreen
fun provideRemoveLinkMark(): RemoveLinkMark = RemoveLinkMark()
@Provides
@PerScreen
fun provideUpdateTextStyleUseCase(

View file

@ -28,4 +28,5 @@ interface MainComponent {
fun detailEditBuilder(): DetailEditSubComponent.Builder
fun detailsReorderBuilder(): DetailsReorderSubComponent.Builder
fun pageComponentBuilder(): PageSubComponent.Builder
fun linkAddComponentBuilder(): LinkSubComponent.Builder
}

View file

@ -5,8 +5,8 @@ import android.view.View
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import com.agileburo.anytype.ModalsNavFragment
import com.agileburo.anytype.ModalsNavFragment.Companion.TAG_CUSTOMIZE
import com.agileburo.anytype.ui.database.modals.ModalsNavFragment
import com.agileburo.anytype.ui.database.modals.ModalsNavFragment.Companion.TAG_CUSTOMIZE
import com.agileburo.anytype.R
import com.agileburo.anytype.core_ui.layout.ListDividerItemDecoration
import com.agileburo.anytype.core_ui.layout.SpacingItemDecoration

View file

@ -5,8 +5,7 @@ import android.view.View
import android.widget.TextView
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.agileburo.anytype.ModalNavigation
import com.agileburo.anytype.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.ui.database.modals.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.R
import com.agileburo.anytype.core_utils.ext.show
import com.agileburo.anytype.di.common.componentManager

View file

@ -6,8 +6,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.agileburo.anytype.ModalNavigation
import com.agileburo.anytype.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.ui.database.modals.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.R
import com.agileburo.anytype.core_ui.extensions.drawable
import com.agileburo.anytype.core_ui.extensions.invisible

View file

@ -7,8 +7,7 @@ import android.view.ViewGroup
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import com.agileburo.anytype.ModalNavigation
import com.agileburo.anytype.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.ui.database.modals.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.R
import com.agileburo.anytype.core_ui.layout.ListDividerItemDecoration
import com.agileburo.anytype.core_utils.ui.BaseBottomSheetFragment

View file

@ -8,8 +8,7 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.agileburo.anytype.ModalNavigation
import com.agileburo.anytype.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.ui.database.modals.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.R
import com.agileburo.anytype.core_ui.extensions.invisible
import com.agileburo.anytype.core_ui.extensions.visible

View file

@ -1,14 +1,15 @@
package com.agileburo.anytype
package com.agileburo.anytype.ui.database.modals
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import com.agileburo.anytype.ui.database.modals.*
import com.agileburo.anytype.R
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class ModalsNavFragment : BottomSheetDialogFragment(), ModalNavigation {
class ModalsNavFragment : BottomSheetDialogFragment(),
ModalNavigation {
companion object {
const val TAG_CUSTOMIZE = "tag.customize"

View file

@ -5,8 +5,7 @@ import android.view.View
import android.widget.ImageView
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.agileburo.anytype.ModalNavigation
import com.agileburo.anytype.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.ui.database.modals.ModalsNavFragment.Companion.ARGS_DB_ID
import com.agileburo.anytype.R
import com.agileburo.anytype.core_utils.ext.hide
import com.agileburo.anytype.core_utils.ext.show

View file

@ -36,10 +36,13 @@ import com.agileburo.anytype.core_utils.ext.toast
import com.agileburo.anytype.di.common.componentManager
import com.agileburo.anytype.domain.block.model.Block.Content.Text
import com.agileburo.anytype.domain.common.Id
import com.agileburo.anytype.domain.ext.getFirstLinkMarkupParam
import com.agileburo.anytype.domain.ext.getSubstring
import com.agileburo.anytype.ext.extractMarks
import com.agileburo.anytype.presentation.page.PageViewModel
import com.agileburo.anytype.presentation.page.PageViewModelFactory
import com.agileburo.anytype.ui.base.NavigationFragment
import com.agileburo.anytype.ui.page.modals.LinkFragment
import com.google.android.material.bottomsheet.BottomSheetBehavior
import kotlinx.android.synthetic.main.fragment_page.*
import kotlinx.coroutines.delay
@ -50,7 +53,7 @@ import timber.log.Timber
import javax.inject.Inject
class PageFragment : NavigationFragment(R.layout.fragment_page) {
class PageFragment : NavigationFragment(R.layout.fragment_page), OnFragmentInteractionListener {
private val pageAdapter by lazy {
BlockAdapter(
@ -84,7 +87,6 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
super.onCreate(savedInstanceState)
vm.open(requireArguments().getString(ID_KEY, ID_EMPTY_VALUE))
requireActivity()
.onBackPressedDispatcher
.addCallback(this) {
@ -238,6 +240,14 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
}
}
override fun onAddMarkupLinkClicked(blockId: String, link: String, range: IntRange) {
vm.onAddLinkPressed(blockId, link, range)
}
override fun onRemoveMarkupLinkClicked(blockId: String, range: IntRange) {
vm.onUnlinkPressed(blockId, range)
}
private fun handleBackgroundColorClicked() {
toast(NOT_IMPLEMENTED_MESSAGE)
}
@ -286,6 +296,15 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
is PageViewModel.ViewState.Success -> {
pageAdapter.updateWithDiffUtil(state.blocks)
}
is PageViewModel.ViewState.OpenLinkScreen -> {
LinkFragment.newInstance(
blockId = state.block.id,
initUrl = state.block.getFirstLinkMarkupParam(state.range),
text = state.block.getSubstring(state.range),
rangeEnd = state.range.last,
rangeStart = state.range.first
).show(childFragmentManager, null)
}
}
}
@ -370,4 +389,9 @@ class PageFragment : NavigationFragment(R.layout.fragment_page) {
const val ID_EMPTY_VALUE = ""
const val NOT_IMPLEMENTED_MESSAGE = "Not implemented."
}
}
interface OnFragmentInteractionListener {
fun onAddMarkupLinkClicked(blockId: String, link: String, range: IntRange)
fun onRemoveMarkupLinkClicked(blockId: String, range: IntRange)
}

View file

@ -0,0 +1,117 @@
package com.agileburo.anytype.ui.page.modals
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.agileburo.anytype.R
import com.agileburo.anytype.core_utils.ui.BaseBottomSheetFragment
import com.agileburo.anytype.di.common.componentManager
import com.agileburo.anytype.presentation.page.LinkAddViewModel
import com.agileburo.anytype.presentation.page.LinkAddViewModelFactory
import com.agileburo.anytype.presentation.page.LinkViewState
import com.agileburo.anytype.ui.page.OnFragmentInteractionListener
import kotlinx.android.synthetic.main.fragment_link.*
import javax.inject.Inject
class LinkFragment : BaseBottomSheetFragment() {
companion object {
const val ARG_URL = "arg.link.url"
const val ARG_TEXT = "arg.link.text"
const val ARG_RANGE_START = "arg.link.range.start"
const val ARG_RANGE_END = "arg.link.range.end"
const val ARG_BLOCK_ID = "arg.link.block.id"
fun newInstance(
text: String,
initUrl: String?,
rangeStart: Int,
rangeEnd: Int,
blockId: String
) =
LinkFragment().apply {
arguments = bundleOf(
ARG_TEXT to text,
ARG_URL to initUrl,
ARG_RANGE_START to rangeStart,
ARG_RANGE_END to rangeEnd,
ARG_BLOCK_ID to blockId
)
}
}
@Inject
lateinit var factory: LinkAddViewModelFactory
private val vm by lazy {
ViewModelProviders
.of(this, factory)
.get(LinkAddViewModel::class.java)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(DialogFragment.STYLE_NORMAL, R.style.DialogStyle)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_link, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
vm.state.observe(viewLifecycleOwner, Observer { state -> render(state) })
arguments?.let {
vm.onViewCreated(
text = it.getString(ARG_TEXT, ""),
initUrl = it.getString(ARG_URL),
range = IntRange(it.getInt(ARG_RANGE_START), it.getInt(ARG_RANGE_END))
)
}
}
private fun render(state: LinkViewState) {
when (state) {
is LinkViewState.Init -> {
text.text = state.text
link.setText(state.url)
buttonLink.setOnClickListener {
vm.onLinkButtonClicked(link.text.toString())
}
buttonUnlink.setOnClickListener {
vm.onUnlinkButtonClicked()
}
}
is LinkViewState.AddLink -> {
(parentFragment as? OnFragmentInteractionListener)?.onAddMarkupLinkClicked(
link = state.link,
range = state.range,
blockId = arguments?.getString(ARG_BLOCK_ID, "").orEmpty()
)
dismiss()
}
is LinkViewState.Unlink -> {
(parentFragment as? OnFragmentInteractionListener)?.onRemoveMarkupLinkClicked(
range = state.range,
blockId = arguments?.getString(ARG_BLOCK_ID, "").orEmpty()
)
dismiss()
}
}
}
override fun injectDependencies() {
componentManager().linkAddComponent.get().inject(this)
}
override fun releaseDependencies() {
componentManager().linkAddComponent.release()
}
}

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/modal_rect_margin_top"
android:contentDescription="@string/content_description_modal_icon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/sheet_top" />
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="19dp"
android:layout_marginEnd="20dp"
android:textColor="#ACA996"
android:textSize="15sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView"
tools:text="receiving notifications because" />
<View
android:id="@+id/divider1"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="@dimen/modal_main_margin_start"
android:layout_marginTop="13dp"
android:layout_marginEnd="@dimen/modal_main_margin_end"
android:background="@color/divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text" />
<EditText
android:id="@+id/link"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="13dp"
android:layout_marginEnd="20dp"
android:background="@null"
android:hint="@string/hint_link"
android:inputType="textUri"
android:textColor="#ACA996"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider1" />
<View
android:id="@+id/divider2"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="@dimen/modal_main_margin_start"
android:layout_marginTop="13dp"
android:layout_marginEnd="@dimen/modal_main_margin_end"
android:background="@color/divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/link" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/buttonUnlink"
android:layout_width="@dimen/modal_button_width"
android:layout_height="@dimen/modal_button_height"
android:layout_marginStart="20dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:background="@drawable/rounded_button_cancel"
android:text="@string/button_unlink"
android:textAllCaps="false"
android:fontFamily="@font/graphik_medium"
android:textColor="#ACA996"
android:stateListAnimator="@animator/scale_shrink"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider2" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/buttonLink"
android:layout_width="@dimen/modal_button_width"
android:layout_height="@dimen/modal_button_height"
android:layout_marginEnd="@dimen/modal_main_margin_end"
android:background="@drawable/rounded_button_add"
android:fontFamily="@font/graphik_medium"
android:stateListAnimator="@animator/scale_shrink"
android:text="@string/button_link"
android:textAllCaps="false"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/buttonUnlink" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -5,6 +5,6 @@
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".ModalsNavFragment">
tools:context=".ui.database.modals.ModalsNavFragment">
</FrameLayout>

View file

@ -87,5 +87,8 @@ Do the computation of an expensive paragraph of text on a background thread:
<string name="create_a_new_profile">Create a new profile</string>
<string name="no_peers">No peers</string>
<string name="content_description_back_button_icon">Back button icon</string>
<string name="hint_link">Paste or type a URL</string>
<string name="button_unlink">Unlink</string>
<string name="button_link">Link</string>
</resources>

View file

@ -59,4 +59,10 @@
<item name="android:background">@drawable/selector_pin_code_keyboard</item>
</style>
<style name="DialogStyle" parent="Theme.Design.Light.BottomSheetDialog">
<item name="android:windowIsFloating">false</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowSoftInputMode">adjustResize</item>
</style>
</resources>

View file

@ -5,10 +5,8 @@ import android.graphics.Typeface
import android.text.Editable
import android.text.Spannable
import android.text.SpannableString
import android.text.style.CharacterStyle
import android.text.style.ForegroundColorSpan
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.*
import android.text.util.Linkify
/**
* Classes implementing this interface should support markup rendering.
@ -48,7 +46,8 @@ interface Markup {
ITALIC,
BOLD,
STRIKETHROUGH,
TEXT_COLOR
TEXT_COLOR,
LINK
}
}
@ -79,6 +78,12 @@ fun Markup.toSpannable() = SpannableString(body).apply {
mark.to,
Spannable.SPAN_INCLUSIVE_INCLUSIVE
)
Markup.Type.LINK -> setSpan(
URLSpan(mark.param as String),
mark.from,
mark.to,
Spannable.SPAN_INCLUSIVE_INCLUSIVE
)
}
}
}
@ -113,6 +118,14 @@ fun Editable.setMarkup(markup: Markup) {
mark.to,
Spannable.SPAN_INCLUSIVE_INCLUSIVE
)
Markup.Type.LINK -> {
setSpan(
URLSpan(mark.param as String),
mark.from,
mark.to,
Spannable.SPAN_INCLUSIVE_INCLUSIVE
)
}
}
}
}

View file

@ -2,6 +2,8 @@ package com.agileburo.anytype.core_ui.widgets.text
import android.content.Context
import android.text.TextWatcher
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatEditText
import timber.log.Timber
@ -20,6 +22,10 @@ class TextInputWidget : AppCompatEditText {
defStyle
)
init {
makeLinksActive()
}
override fun addTextChangedListener(watcher: TextWatcher) {
watchers.add(watcher)
super.addTextChangedListener(watcher)
@ -40,4 +46,12 @@ class TextInputWidget : AppCompatEditText {
selectionDetector?.invoke(selStart..selEnd)
super.onSelectionChanged(selStart, selEnd)
}
/**
* Makes all links in the TextView object active.
*/
private fun makeLinksActive() {
this.movementMethod = LinkMovementMethod.getInstance()
Linkify.addLinks(this, Linkify.WEB_URLS)
}
}

View file

@ -40,10 +40,11 @@ class MarkupToolbarWidget : ConstraintLayout {
LayoutInflater.from(context).inflate(R.layout.widget_markup_toolbar, this)
}
fun markupClicks() = flowOf(bold(), italic(), strike()).flattenMerge()
fun markupClicks() = flowOf(bold(), italic(), strike(), link()).flattenMerge()
private fun bold() = bold.clicks().map { Markup.Type.BOLD }
private fun italic() = italic.clicks().map { Markup.Type.ITALIC }
private fun strike() = strike.clicks().map { Markup.Type.STRIKETHROUGH }
private fun link() = link.clicks().map { Markup.Type.LINK }
fun colorClicks() = color.clicks()
fun hideKeyboardClicks() = keyboard.clicks()

View file

@ -4,7 +4,7 @@
<corners android:radius="4dp" />
<solid android:color="#FFB522" />
<stroke
android:width="1px"
android:width="1dp"
android:color="#FFB522" />
</shape>

View file

@ -4,7 +4,7 @@
<corners android:radius="4dp" />
<solid android:color="@color/white" />
<stroke
android:width="1px"
android:width="1dp"
android:color="#DFDDD0" />
</shape>

View file

@ -10,9 +10,6 @@ android {
defaultConfig {
minSdkVersion config["min_sdk"]
targetSdkVersion config["target_sdk"]
versionCode config["version_code"]
versionName config["version_name"]
testInstrumentationRunner config["test_runner"]
}
@ -22,13 +19,13 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
def applicationDependencies = rootProject.ext.mainApplication
def unitTestDependencies = rootProject.ext.unitTesting
def acceptanceTesting = rootProject.ext.acceptanceTesting
kapt applicationDependencies.daggerCompiler
@ -51,4 +48,9 @@ dependencies {
implementation applicationDependencies.navigation
implementation applicationDependencies.navigationUi
testImplementation unitTestDependencies.robolectric
androidTestImplementation acceptanceTesting.androidJUnit
androidTestImplementation acceptanceTesting.testRules
testImplementation unitTestDependencies.archCoreTesting
}

View file

@ -5,6 +5,7 @@ import android.content.res.Resources
import android.graphics.Rect
import android.net.Uri
import android.provider.MediaStore
import android.text.Spanned
import android.view.TouchDelegate
import android.view.View
import timber.log.Timber
@ -73,4 +74,9 @@ private fun expandViewHitArea(parent: View, child: View) {
childRect.bottom = parentRect.height()
parent.touchDelegate = TouchDelegate(childRect, child)
}
}
fun <T> hasSpan(spanned: Spanned, clazz: Class<T>): Boolean {
val limit = spanned.length
return spanned.nextSpanTransition(0, limit, clazz) < limit
}

View file

@ -0,0 +1,50 @@
package com.agileburo.anytype
import android.graphics.Color
import android.text.Spannable
import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import android.text.style.URLSpan
import android.text.style.UnderlineSpan
import com.agileburo.anytype.core_utils.ext.hasSpan
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@Config(manifest = Config.NONE)
@RunWith(RobolectricTestRunner::class)
class SpanExtensionsTest {
@Test
fun `should find spans`() {
val text = "Testing Spans"
val spannable = SpannableString(text)
spannable.setSpan(
URLSpan("https://anytype.io/"),
0,
text.lastIndex,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
spannable.setSpan(
BackgroundColorSpan(Color.RED),
0,
6,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
assertTrue(hasSpan(spannable, URLSpan::class.java))
assertTrue(hasSpan(spannable, BackgroundColorSpan::class.java))
assertFalse(hasSpan(spannable, UnderlineSpan::class.java))
}
@Test
fun `should not find spans`() {
val text = "Testing Spans"
val spannable = SpannableString(text)
assertFalse(hasSpan(spannable, URLSpan::class.java))
}
}

View file

@ -37,7 +37,7 @@ ext {
rxbinding_version = '3.0.0'
// Unit Testing
robolectric_version = '4.3.1'
robolectric_version = '4.3'
junit_version = '4.12'
mockito_version = '1.4.0'
kluent_version = '1.14'

View file

@ -0,0 +1,40 @@
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.Block
import com.agileburo.anytype.domain.ext.rangeIntersection
/**
* Remove all link marks from list with intersected ranges
* with given range.
*/
class RemoveLinkMark : BaseUseCase<List<Block.Content.Text.Mark>, RemoveLinkMark.Params>() {
override suspend fun run(params: Params): Either<Throwable, List<Block.Content.Text.Mark>> =
try {
val result = mutableListOf<Block.Content.Text.Mark>()
params.marks.forEach {
if (it.type != Block.Content.Text.Mark.Type.LINK) {
result.add(it)
} else {
if (it.rangeIntersection(params.range) == 0) {
result.add(it)
}
}
}
Either.Right(result.toList())
} catch (t: Throwable) {
Either.Left(t)
}
/**
* @property marks Collection to remove link marks
* @property range Given range to find intersections
*/
class Params(
val marks: List<Block.Content.Text.Mark>,
val range: IntRange
)
}

View file

@ -0,0 +1,42 @@
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.Block
import com.agileburo.anytype.domain.ext.rangeIntersection
/**
* Adds new link mark to the list of marks and
* remove all link marks with intersected ranges
* with new mark.
*/
class UpdateLinkMarks : BaseUseCase<List<Block.Content.Text.Mark>, UpdateLinkMarks.Params>() {
override suspend fun run(params: Params): Either<Throwable, List<Block.Content.Text.Mark>> =
try {
val result = mutableListOf<Block.Content.Text.Mark>()
params.marks.forEach {
if (it.type != Block.Content.Text.Mark.Type.LINK) {
result.add(it)
} else {
if (it.rangeIntersection(params.newMark.range) == 0) {
result.add(it)
}
}
}
result.add(params.newMark)
Either.Right(result.toList())
} catch (t: Throwable) {
Either.Left(t)
}
/**
* @property marks Collection of marks to update
* @property newMark Link mark to add
*/
class Params(
val marks: List<Block.Content.Text.Mark>,
val newMark: Block.Content.Text.Mark
)
}

View file

@ -42,6 +42,21 @@ fun Block.textStyle(): Block.Content.Text.Style {
throw UnsupportedOperationException("Wrong block content type: ${content.javaClass}")
}
fun Block.Content.Text.Mark.rangeIntersection(range: IntRange): Int {
val markRange = IntRange(start = this.range.first, endInclusive = this.range.last)
val set = markRange.intersect(range)
return set.size
}
fun Block.getFirstLinkMarkupParam(range: IntRange): String? {
val marks = this.content.asText().marks
return marks.filter { mark -> mark.type == Block.Content.Text.Mark.Type.LINK }
.firstOrNull { mark: Block.Content.Text.Mark ->
mark.rangeIntersection(range) > 0
}.let { mark: Block.Content.Text.Mark? -> mark?.param as? String }
}
fun Block.getSubstring(range: IntRange): String = content.asText().text.substring(range)
fun Block.textColor(): String? {
if (content is Block.Content.Text)
return content.color

View file

@ -0,0 +1,30 @@
package com.agileburo.anytype.domain.page
import com.agileburo.anytype.domain.base.BaseUseCase
import com.agileburo.anytype.domain.base.Either
/**
* Created by Konstantin Ivanov
* email : ki@agileburo.com
* on 2020-01-23.
*/
/**
* Use-case for unlinking urls from text.
*/
class CheckForUnlink : BaseUseCase<Boolean, CheckForUnlink.Params>() {
override suspend fun run(params: Params): Either<Throwable, Boolean> = try {
if (params.link.isNullOrEmpty()) {
Either.Left(NothingToUnlinkException())
} else {
Either.Right(true)
}
} catch (e: Throwable) {
Either.Left(e)
}
class Params(val link: String?)
}
class NothingToUnlinkException : Exception("No text to unlink")

View file

@ -0,0 +1,128 @@
package com.agileburo.anytype.domain.block.interactor
import com.agileburo.anytype.domain.block.model.Block
import kotlinx.coroutines.runBlocking
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
class RemoveLinkMarkTest {
lateinit var removeLinkMark: RemoveLinkMark
@Before
fun setup() {
removeLinkMark = RemoveLinkMark()
}
@Test
fun `should remove link mark with range`() {
runBlocking {
val marks = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
param = "wwww.yahoo.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.google.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
param = "wwww.yandex.ru",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(21, 56),
param = "wwww.allmusic.com",
type = Block.Content.Text.Mark.Type.LINK
)
)
val range = IntRange(6, 16)
val expected = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
param = "wwww.yahoo.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
param = "wwww.yandex.ru",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(21, 56),
param = "wwww.allmusic.com",
type = Block.Content.Text.Mark.Type.LINK
)
)
val result = removeLinkMark.run(params = RemoveLinkMark.Params(marks, range))
result.either(
{ fail() },
{ kotlin.test.assertEquals(expected, it) }
)
}
}
@Test
fun `should not remove link mark`() {
runBlocking {
val marks = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
param = "wwww.yahoo.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.google.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(110, 220),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
)
)
val range = IntRange(110, 220)
val expected = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
param = "wwww.yahoo.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.google.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(110, 220),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
)
)
val result = removeLinkMark.run(params = RemoveLinkMark.Params(marks, range))
result.either(
{ fail() },
{ kotlin.test.assertEquals(expected, it) }
)
}
}
}

View file

@ -0,0 +1,291 @@
package com.agileburo.anytype.domain.block.interactor
import com.agileburo.anytype.domain.block.model.Block
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
class UpdateLinkMarksTest {
lateinit var updateLinkMarks: UpdateLinkMarks
@Before
fun setup() {
updateLinkMarks = UpdateLinkMarks()
}
@Test
fun `should return updated list without link marks with intersections`() {
runBlocking {
val marks = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
param = "wwww.yahoo.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.google.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
param = "wwww.yandex.ru",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(21, 56),
param = "wwww.allmusic.com",
type = Block.Content.Text.Mark.Type.LINK
)
)
val newMark = Block.Content.Text.Mark(
range = IntRange(4, 20),
param = "wwww.anytype.io",
type = Block.Content.Text.Mark.Type.LINK
)
val expected = listOf(
Block.Content.Text.Mark(
range = IntRange(21, 56),
param = "wwww.allmusic.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(4, 20),
param = "wwww.anytype.io",
type = Block.Content.Text.Mark.Type.LINK
)
)
val result = updateLinkMarks.run(params = UpdateLinkMarks.Params(marks, newMark))
result.either(
{ Assert.fail() },
{ assertEquals(expected, it) }
)
}
}
@Test
fun `should return updated list of marks, when no intersections`() {
runBlocking {
val marks = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
param = "wwww.yahoo.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.google.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
param = "wwww.yandex.ru",
type = Block.Content.Text.Mark.Type.LINK
)
)
val newMark = Block.Content.Text.Mark(
range = IntRange(25, 35),
param = "wwww.anytype.io",
type = Block.Content.Text.Mark.Type.LINK
)
val expected = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
param = "wwww.yahoo.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.google.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
param = "wwww.yandex.ru",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(25, 35),
param = "wwww.anytype.io",
type = Block.Content.Text.Mark.Type.LINK
)
)
val result = updateLinkMarks.run(params = UpdateLinkMarks.Params(marks, newMark))
result.either(
{ Assert.fail() },
{ assertEquals(expected, it) }
)
}
}
@Test
fun `should return updated list of marks, when no link marks and no intersections`() {
runBlocking {
val marks = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
type = Block.Content.Text.Mark.Type.BOLD
)
)
val newMark = Block.Content.Text.Mark(
range = IntRange(25, 35),
param = "wwww.anytype.io",
type = Block.Content.Text.Mark.Type.LINK
)
val expected = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(25, 35),
param = "wwww.anytype.io",
type = Block.Content.Text.Mark.Type.LINK
)
)
val result = updateLinkMarks.run(params = UpdateLinkMarks.Params(marks, newMark))
result.either(
{ Assert.fail() },
{ assertEquals(expected, it) }
)
}
}
@Test
fun `should return updated list of marks, when no link marks`() {
runBlocking {
val marks = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
type = Block.Content.Text.Mark.Type.BOLD
)
)
val newMark = Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.anytype.io",
type = Block.Content.Text.Mark.Type.LINK
)
val expected = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.anytype.io",
type = Block.Content.Text.Mark.Type.LINK
)
)
val result = updateLinkMarks.run(params = UpdateLinkMarks.Params(marks, newMark))
result.either(
{ Assert.fail() },
{ assertEquals(expected, it) }
)
}
}
@Test
fun `should return updated list when new mark param is empty`() {
runBlocking {
val marks = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
param = "wwww.yahoo.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(6, 16),
param = "wwww.google.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(18, 24),
param = "wwww.yandex.ru",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(21, 56),
param = "wwww.allmusic.com",
type = Block.Content.Text.Mark.Type.LINK
)
)
val newMark = Block.Content.Text.Mark(
range = IntRange(4, 20),
param = "",
type = Block.Content.Text.Mark.Type.LINK
)
val expected = listOf(
Block.Content.Text.Mark(
range = IntRange(21, 56),
param = "wwww.allmusic.com",
type = Block.Content.Text.Mark.Type.LINK
),
Block.Content.Text.Mark(
range = IntRange(4, 20),
param = "",
type = Block.Content.Text.Mark.Type.LINK
)
)
val result = updateLinkMarks.run(params = UpdateLinkMarks.Params(marks, newMark))
result.either(
{ Assert.fail() },
{ assertEquals(expected, it) }
)
}
}
}

View file

@ -4,6 +4,9 @@ import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.common.MockDataFactory
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
class BlockExtensionTest {
@ -441,4 +444,287 @@ class BlockExtensionTest {
actual = rendering[4]
)
}
@Test
fun `should return substring of block text by range`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = emptyList(),
style = Block.Content.Text.Style.P,
text = "Test block 123"
),
children = emptyList()
)
val range = IntRange(5, 12)
val result = block.getSubstring(range)
assertEquals("block 12", result)
}
@Test(expected = IndexOutOfBoundsException::class)
fun `should return error when range is out of bounds`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = emptyList(),
style = Block.Content.Text.Style.P,
text = "Test"
),
children = emptyList()
)
val range = IntRange(0, 4)
block.getSubstring(range)
}
@Test(expected = ClassCastException::class)
fun `should return error when block not text`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Page(
style = Block.Content.Page.Style.EMPTY
),
children = emptyList()
)
val range = IntRange(0, 44)
block.getSubstring(range)
}
@Test
fun `should return not empty range intersection`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = listOf(
Block.Content.Text.Mark(
range = IntRange(
start = 5,
endInclusive = 8
),
type = Block.Content.Text.Mark.Type.BOLD
)
),
style = Block.Content.Text.Style.P,
text = "Test Bold text"
),
children = emptyList()
)
val range = IntRange(7, 144)
val result = block.content.asText().marks[0].rangeIntersection(range)
assertEquals(2, result)
}
@Test
fun `should return empty range intersection`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = listOf(
Block.Content.Text.Mark(
range = IntRange(
start = 5,
endInclusive = 8
),
type = Block.Content.Text.Mark.Type.BOLD
)
),
style = Block.Content.Text.Style.P,
text = "Test Bold text"
),
children = emptyList()
)
val range = IntRange(0, 4)
val result = block.content.asText().marks[0].rangeIntersection(range)
assertEquals(0, result)
}
@Test
fun `should return link markup`() {
val link = Block.Content.Text.Mark(
range = IntRange(
start = 10,
endInclusive = 13
),
type = Block.Content.Text.Mark.Type.LINK,
param = "www.anytype.io/test"
)
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = listOf(
Block.Content.Text.Mark(
range = IntRange(
start = 5,
endInclusive = 8
),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(
start = 10,
endInclusive = 13
),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
),
link
),
style = Block.Content.Text.Style.P,
text = "Test Bold text"
),
children = emptyList()
)
val range = IntRange(10, 13)
val result = block.getFirstLinkMarkupParam(range)
assertEquals(link.param, result)
}
@Test
fun `should return nullable markup`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = listOf(
Block.Content.Text.Mark(
range = IntRange(
start = 5,
endInclusive = 8
),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(
start = 10,
endInclusive = 13
),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
)
),
style = Block.Content.Text.Style.P,
text = "Test Bold text"
),
children = emptyList()
)
val range = IntRange(10, 13)
val result = block.getFirstLinkMarkupParam(range)
assertNull(result)
}
@Test
fun `should return first link markup`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = listOf(
Block.Content.Text.Mark(
range = IntRange(
start = 0,
endInclusive = 8
),
type = Block.Content.Text.Mark.Type.LINK,
param = "https://kotlinlang.ru"
),
Block.Content.Text.Mark(
range = IntRange(
start = 0,
endInclusive = 8
),
type = Block.Content.Text.Mark.Type.LINK,
param = "https://ya.ru/"
)
),
style = Block.Content.Text.Style.P,
text = "Test Bold text"
),
children = emptyList()
)
val range = IntRange(0, 8)
val result = block.getFirstLinkMarkupParam(range)
assertEquals("https://kotlinlang.ru", result)
}
@Test
fun `should return nullable markup when no marks in block`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = emptyList(),
style = Block.Content.Text.Style.P,
text = "Test Bold text"
),
children = emptyList()
)
val range = IntRange(10, 13)
val result = block.getFirstLinkMarkupParam(range)
assertNull(result)
}
@Test
fun `should return nullable markup when link param not string`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Text(
marks = listOf(
Block.Content.Text.Mark(
range = IntRange(
start = 0,
endInclusive = 8
),
type = Block.Content.Text.Mark.Type.LINK,
param = 45678
)
),
style = Block.Content.Text.Style.P,
text = "Test Bold text"
),
children = emptyList()
)
val range = IntRange(0, 8)
val result = block.getFirstLinkMarkupParam(range)
assertNull(result)
}
@Test(expected = ClassCastException::class)
fun `should throw exception when block is not text`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Dashboard(
type = Block.Content.Dashboard.Type.MAIN_SCREEN
),
children = emptyList()
)
val range = IntRange(10, 13)
val result = block.getFirstLinkMarkupParam(range)
assertNull(result)
}
}

View file

@ -0,0 +1,59 @@
package com.agileburo.anytype.domain.page
import com.agileburo.anytype.domain.common.CoroutineTestRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
class UnlinkTextTest {
@ExperimentalCoroutinesApi
@get:Rule
var rule = CoroutineTestRule()
lateinit var checkForUnlink: CheckForUnlink
@Before
fun setup() {
checkForUnlink = CheckForUnlink()
}
@Test
fun `should return NothingToUnlinkException when url is empty`() {
runBlocking {
val params = CheckForUnlink.Params(link = "")
val result = checkForUnlink.run(params)
result.either(
{ throwable ->
assertEquals("No text to unlink", throwable.localizedMessage)
},
{ b: Boolean ->
Assert.fail()
})
}
}
@Test
fun `should return NothingToUnlinkException when url is null`() {
runBlocking {
val params = CheckForUnlink.Params( link = null)
val result = checkForUnlink.run(params)
result.either(
{ throwable ->
assertEquals("No text to unlink", throwable.localizedMessage)
},
{ b: Boolean ->
Assert.fail()
})
}
}
}

View file

@ -80,6 +80,14 @@ fun BlockEntity.Content.Text.Mark.toMiddleware(): Block.Content.Text.Mark {
.setParam(param as String)
.build()
}
BlockEntity.Content.Text.Mark.Type.LINK -> {
Block.Content.Text.Mark
.newBuilder()
.setType(Block.Content.Text.Mark.Type.Link)
.setRange(rangeModel)
.setParam(param as String)
.build()
}
else -> throw IllegalStateException("Unsupported mark type: ${type.name}")
}
}
@ -149,6 +157,9 @@ fun Block.text(): BlockEntity.Content.Text = BlockEntity.Content.Text(
Block.Content.Text.Mark.Type.BackgroundColor -> {
BlockEntity.Content.Text.Mark.Type.BACKGROUND_COLOR
}
Block.Content.Text.Mark.Type.Link -> {
BlockEntity.Content.Text.Mark.Type.LINK
}
else -> throw IllegalStateException("Unexpected mark type: ${mark.type.name}")
}
)

View file

@ -429,6 +429,9 @@ class BlockMiddleware(
Models.Block.Content.Text.Mark.Type.BackgroundColor -> {
BlockEntity.Content.Text.Mark.Type.BACKGROUND_COLOR
}
Models.Block.Content.Text.Mark.Type.Link -> {
BlockEntity.Content.Text.Mark.Type.LINK
}
else -> throw IllegalStateException("Unexpected mark type: ${mark.type.name}")
}
)

View file

@ -1,5 +1,6 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
@ -14,6 +15,10 @@ android {
}
testOptions.unitTests.includeAndroidResources = true
androidExtensions {
experimental = true
}
}
dependencies {

View file

@ -112,6 +112,14 @@ private fun mapMarks(content: Block.Content.Text): List<Markup.Mark> =
param = checkNotNull(mark.param)
)
}
Block.Content.Text.Mark.Type.LINK -> {
Markup.Mark(
from = mark.range.first,
to = mark.range.last,
type = Markup.Type.LINK,
param = checkNotNull(mark.param)
)
}
else -> null
}
}

View file

@ -49,11 +49,11 @@ interface AppNavigation {
object StartDesktopFromLogin : Command()
object StartSplashFromDesktop : Command()
object OpenContactsScreen : Command()
object OpenDatabaseViewAddView: Command()
object OpenEditDatabase: Command()
object OpenSwitchDisplayView: Command()
object OpenCustomizeDisplayView: Command()
object OpenKanbanScreen: Command()
object OpenDatabaseViewAddView : Command()
object OpenEditDatabase : Command()
object OpenSwitchDisplayView : Command()
object OpenCustomizeDisplayView : Command()
object OpenKanbanScreen : Command()
object OpenGoalsScreen : Command()
}

View file

@ -0,0 +1,55 @@
package com.agileburo.anytype.presentation.page
import androidx.lifecycle.*
import com.agileburo.anytype.domain.page.CheckForUnlink
import timber.log.Timber
sealed class LinkViewState {
data class Init(val text: String, val url: String?) : LinkViewState()
data class AddLink(val link: String, val range: IntRange) : LinkViewState()
data class Unlink(val range: IntRange) : LinkViewState()
}
class LinkAddViewModel(
private val checkForUnlink: CheckForUnlink
) : ViewModel() {
lateinit var range: IntRange
private var initUrl: String? = null
private val stateData = MutableLiveData<LinkViewState>()
val state: LiveData<LinkViewState> = stateData
fun onViewCreated(initUrl: String?, text: String, range: IntRange) {
this.range = range
this.initUrl = initUrl
stateData.value = LinkViewState.Init(
text = text,
url = initUrl
)
}
fun onLinkButtonClicked(text: String) {
if (text.isNotEmpty()) {
stateData.value = LinkViewState.AddLink(link = text, range = range)
}
}
fun onUnlinkButtonClicked() =
checkForUnlink.invoke(viewModelScope, CheckForUnlink.Params(link = initUrl)) { result ->
result.either(
fnL = { Timber.e("Can't proceed to unlink:${it.message}") },
fnR = { stateData.postValue(LinkViewState.Unlink(range = range)) }
)
}
}
class LinkAddViewModelFactory(
private val unlink: CheckForUnlink
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return LinkAddViewModel(unlink) as T
}
}

View file

@ -42,7 +42,9 @@ class PageViewModel(
private val unlinkBlocks: UnlinkBlocks,
private val duplicateBlock: DuplicateBlock,
private val updateTextStyle: UpdateTextStyle,
private val updateTextColor: UpdateTextColor
private val updateTextColor: UpdateTextColor,
private val updateLinkMarks: UpdateLinkMarks,
private val removeLinkMark: RemoveLinkMark
) : ViewStateViewModel<PageViewModel.ViewState>(),
SupportNavigation<EventWrapper<AppNavigation.Command>> {
@ -162,11 +164,61 @@ class PageViewModel(
.filter { (_, selection) -> selection.first != selection.last }
) { a, b -> Pair(a, b) }
.onEach { (action, selection) ->
applyMarkup(selection, action)
when (action.type) {
Markup.Type.LINK -> {
val block = blocks.first { it.id == selection.first }
val range = IntRange(
start = selection.second.first,
endInclusive = selection.second.last.dec()
)
stateData.value = ViewState.OpenLinkScreen(pageId, block, range)
}
else -> {
applyMarkup(selection, action)
}
}
}
.launchIn(viewModelScope)
}
private fun applyLinkMarkup(
blockId: String, link: String, range: IntRange
) {
val targetBlock = blocks.first { it.id == blockId }
val targetContent = targetBlock.content as Block.Content.Text
val linkMark = Block.Content.Text.Mark(
type = Block.Content.Text.Mark.Type.LINK,
range = IntRange(start = range.first, endInclusive = range.last.inc()),
param = link
)
val marks = targetContent.marks
updateLinkMarks.invoke(
viewModelScope,
UpdateLinkMarks.Params(marks = marks, newMark = linkMark)
) { result ->
result.either(
fnL = {
throwable -> Timber.e("Error update marks:${throwable.message}")
},
fnR = {
val newContent = targetContent.copy(marks = it)
val newBlock = targetBlock.copy(content = newContent)
rerenderingBlocks(newBlock)
proceedWithUpdatingBlock(
params = UpdateBlock.Params(
contextId = pageId,
text = newBlock.content.asText().text,
blockId = targetBlock.id,
marks = it
)
)
}
)
}
}
private suspend fun applyMarkup(
selection: Pair<String, IntRange>,
action: MarkupAction
@ -181,6 +233,7 @@ class PageViewModel(
Markup.Type.ITALIC -> Block.Content.Text.Mark.Type.ITALIC
Markup.Type.STRIKETHROUGH -> Block.Content.Text.Mark.Type.STRIKETHROUGH
Markup.Type.TEXT_COLOR -> Block.Content.Text.Mark.Type.TEXT_COLOR
Markup.Type.LINK -> Block.Content.Text.Mark.Type.LINK
},
param = action.param
)
@ -214,6 +267,18 @@ class PageViewModel(
)
}
private fun rerenderingBlocks(block: Block) =
viewModelScope.launch {
val update = blocks.map {
if (it.id != block.id)
it
else
block
}
blocks = update
renderingChannel.send(blocks)
}
private fun processRendering() {
viewModelScope.launch {
renderings.withLatestFrom(focusChanges) { models, focus ->
@ -288,6 +353,37 @@ class PageViewModel(
}
}
fun onAddLinkPressed(blockId: String, link: String, range: IntRange) {
applyLinkMarkup(blockId, link, range)
}
fun onUnlinkPressed(blockId: String, range: IntRange) {
val targetBlock = blocks.first { it.id == blockId }
val targetContent = targetBlock.content as Block.Content.Text
val marks = targetContent.marks
removeLinkMark.invoke(
viewModelScope, RemoveLinkMark.Params(range = range, marks = marks)
) { result ->
result.either(
fnL = { Timber.e("Error update marks:${it.message}") },
fnR = {
val newContent = targetContent.copy(marks = it)
val newBlock = targetBlock.copy(content = newContent)
rerenderingBlocks(newBlock)
proceedWithUpdatingBlock(
params = UpdateBlock.Params(
contextId = pageId,
text = newBlock.content.asText().text,
blockId = targetBlock.id,
marks = it
)
)
}
)
}
}
fun onSystemBackPressed() {
closePage.invoke(viewModelScope, ClosePage.Params(pageId)) { result ->
result.either(
@ -542,6 +638,8 @@ class PageViewModel(
object Loading : ViewState()
data class Success(val blocks: List<BlockView>) : ViewState()
data class Error(val message: String) : ViewState()
data class OpenLinkScreen(val pageId: String, val block: Block, val range: IntRange) :
ViewState()
}
companion object {

View file

@ -17,7 +17,9 @@ class PageViewModelFactory(
private val unlinkBlocks: UnlinkBlocks,
private val duplicateBlock: DuplicateBlock,
private val updateTextStyle: UpdateTextStyle,
private val updateTextColor: UpdateTextColor
private val updateTextColor: UpdateTextColor,
private val updateLinkMarks: UpdateLinkMarks,
private val removeLinkMark: RemoveLinkMark
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
@ -32,7 +34,9 @@ class PageViewModelFactory(
duplicateBlock = duplicateBlock,
unlinkBlocks = unlinkBlocks,
updateTextStyle = updateTextStyle,
updateTextColor = updateTextColor
updateTextColor = updateTextColor,
updateLinkMarks = updateLinkMarks,
removeLinkMark = removeLinkMark
) as T
}
}