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

Refact | Profile on dashboard with new subscription mechanism (#2051)

This commit is contained in:
Evgenii Kozlov 2022-01-20 17:07:33 +03:00 committed by GitHub
parent 00afad8d9e
commit 4b40bf9f6e
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 792 additions and 308 deletions

View file

@ -8,7 +8,8 @@ import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.block.interactor.Move
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.config.*
import com.anytypeio.anytype.domain.dashboard.interactor.*
import com.anytypeio.anytype.domain.dashboard.interactor.CloseDashboard
import com.anytypeio.anytype.domain.dashboard.interactor.OpenDashboard
import com.anytypeio.anytype.domain.dataview.interactor.SearchObjects
import com.anytypeio.anytype.domain.event.interactor.EventChannel
import com.anytypeio.anytype.domain.event.interactor.InterceptEvents
@ -17,6 +18,7 @@ import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.objects.DeleteObjects
import com.anytypeio.anytype.domain.objects.SetObjectListIsArchived
import com.anytypeio.anytype.domain.page.CreatePage
import com.anytypeio.anytype.domain.search.SubscriptionEventChannel
import com.anytypeio.anytype.presentation.dashboard.HomeDashboardEventConverter
import com.anytypeio.anytype.presentation.dashboard.HomeDashboardViewModelFactory
import com.anytypeio.anytype.ui.dashboard.DashboardFragment
@ -87,9 +89,11 @@ object HomeDashboardModule {
@Provides
@PerScreen
fun provideGetProfileUseCase(
repository: BlockRepository
repository: BlockRepository,
subscriptionEventChannel: SubscriptionEventChannel
): GetProfile = GetProfile(
repo = repository
repo = repository,
channel = subscriptionEventChannel
)
@JvmStatic
@ -167,7 +171,7 @@ object HomeDashboardModule {
@PerScreen
fun provideGetDebugSettings(
repo: InfrastructureRepository
) : GetDebugSettings = GetDebugSettings(
): GetDebugSettings = GetDebugSettings(
repo = repo
)
@ -176,7 +180,7 @@ object HomeDashboardModule {
@PerScreen
fun provideSearchObjects(
repo: BlockRepository
) : SearchObjects = SearchObjects(
): SearchObjects = SearchObjects(
repo = repo
)
@ -191,7 +195,7 @@ object HomeDashboardModule {
@PerScreen
fun deleteObjects(
repo: BlockRepository
) : DeleteObjects = DeleteObjects(
): DeleteObjects = DeleteObjects(
repo = repo
)
@ -200,7 +204,7 @@ object HomeDashboardModule {
@PerScreen
fun setObjectListIsArchived(
repo: BlockRepository
) : SetObjectListIsArchived = SetObjectListIsArchived(
): SetObjectListIsArchived = SetObjectListIsArchived(
repo = repo
)
}

View file

@ -2,13 +2,17 @@ package com.anytypeio.anytype.di.main
import com.anytypeio.anytype.data.auth.event.EventDataChannel
import com.anytypeio.anytype.data.auth.event.EventRemoteChannel
import com.anytypeio.anytype.data.auth.event.SubscriptionDataChannel
import com.anytypeio.anytype.data.auth.event.SubscriptionEventRemoteChannel
import com.anytypeio.anytype.data.auth.status.ThreadStatusDataChannel
import com.anytypeio.anytype.data.auth.status.ThreadStatusRemoteChannel
import com.anytypeio.anytype.domain.event.interactor.EventChannel
import com.anytypeio.anytype.domain.search.SubscriptionEventChannel
import com.anytypeio.anytype.domain.status.ThreadStatusChannel
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.middleware.interactor.EventHandler
import com.anytypeio.anytype.middleware.interactor.MiddlewareEventChannel
import com.anytypeio.anytype.middleware.interactor.MiddlewareSubscriptionEventChannel
import com.anytypeio.anytype.middleware.interactor.ThreadStatusMiddlewareChannel
import dagger.Module
import dagger.Provides
@ -65,4 +69,25 @@ object EventModule {
fun provideEventProxy(): EventProxy {
return EventHandler()
}
@JvmStatic
@Provides
@Singleton
fun provideSubscriptionEventChannel(
channel: SubscriptionDataChannel
): SubscriptionEventChannel = channel
@JvmStatic
@Provides
@Singleton
fun provideSubscriptionEventDataChannel(
remote: SubscriptionEventRemoteChannel
): SubscriptionDataChannel = SubscriptionDataChannel(remote)
@JvmStatic
@Provides
@Singleton
fun provideSubscriptionEventRemoteChannel(
proxy: EventProxy
): SubscriptionEventRemoteChannel = MiddlewareSubscriptionEventChannel(events = proxy)
}

View file

@ -1,20 +1,8 @@
package com.anytypeio.anytype.ui.dashboard
import androidx.recyclerview.widget.RecyclerView
import com.anytypeio.anytype.core_ui.tools.DefaultDragAndDropBehavior
class DashboardDragAndDropBehavior(
onItemMoved: (Int, Int) -> Boolean,
onItemDropped: (Int) -> Unit
) : DefaultDragAndDropBehavior(onItemMoved, onItemDropped) {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
return if (viewHolder is ProfileContainerAdapter.ProfileContainerHolder)
makeMovementFlags(0, 0)
else
super.getMovementFlags(recyclerView, viewHolder)
}
}
) : DefaultDragAndDropBehavior(onItemMoved, onItemDropped)

View file

@ -10,8 +10,10 @@ import androidx.transition.ChangeBounds
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_ui.reactive.clicks
import com.anytypeio.anytype.core_utils.ext.*
import com.anytypeio.anytype.core_utils.ui.ViewState
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.presentation.dashboard.DashboardView
@ -223,6 +225,32 @@ class DashboardFragment : ViewStateFragment<State>(R.layout.fragment_dashboard)
objectRemovalProgressBar.gone()
}
}
jobs += lifecycleScope.subscribe(vm.profile) { profile ->
setProfile(profile)
}
}
private fun setProfile(profile: ViewState<ObjectWrapper.Basic>) {
when (profile) {
is ViewState.Success -> {
val obj = profile.data
avatarContainer.bind(
name = obj.name.orEmpty(),
color = context?.getColor(R.color.dashboard_default_avatar_circle_color)
)
obj.iconImage?.let { avatar ->
avatarContainer.icon(avatar)
}
if (obj.name.isNullOrEmpty()) {
tvGreeting.text = getText(R.string.greet_user)
} else {
tvGreeting.text = getString(R.string.greet, obj.name)
}
}
else -> {
// TODO reset profile view to zero
}
}
}
override fun onPause() {
@ -264,31 +292,10 @@ class DashboardFragment : ViewStateFragment<State>(R.layout.fragment_dashboard)
override fun render(state: State) {
when {
state.error != null -> {
requireActivity().toast("Error: ${state.error}")
}
state.error != null -> toast("Error: ${state.error}")
state.isInitialzed -> {
state.blocks.let { views ->
val profile = views.filterIsInstance<DashboardView.Profile>()
val links = views.filter { it !is DashboardView.Profile && it !is DashboardView.Archive }.groupBy { it.isArchived }
if (profile.isNotEmpty()) {
val view = profile.first()
avatarContainer.bind(
name = view.name,
color = context?.getColor(R.color.dashboard_default_avatar_circle_color)
)
view.avatar?.let { avatar ->
avatarContainer.icon(avatar)
}
if (view.name.isNotEmpty()) {
tvGreeting.text = getString(R.string.greet, view.name)
} else {
tvGreeting.text = getText(R.string.greet_user)
}
}
// TODO refact (no need to filter anything in fragment)
dashboardDefaultAdapter.update(links[false] ?: emptyList())
}
val links = state.blocks.filter { it !is DashboardView.Archive }.groupBy { it.isArchived }
dashboardDefaultAdapter.update(links[false] ?: emptyList())
}
}
}

View file

@ -1,82 +0,0 @@
package com.anytypeio.anytype.ui.dashboard
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_ui.extensions.avatarColor
import com.anytypeio.anytype.core_utils.ext.firstDigitByHash
import com.anytypeio.anytype.core_utils.ext.typeOf
import com.anytypeio.anytype.presentation.dashboard.DashboardView
import kotlinx.android.synthetic.main.item_dashboard_profile_header.view.*
class DashboardProfileAdapter(
private var data: MutableList<DashboardView>,
private val onProfileClicked: () -> Unit
) : RecyclerView.Adapter<DashboardProfileAdapter.ProfileHolder>() {
fun update(views: List<DashboardView>) {
data.clear()
data.addAll(views)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileHolder {
val inflater = LayoutInflater.from(parent.context)
return ProfileHolder(inflater.inflate(R.layout.item_dashboard_profile_header, parent, false))
}
override fun onBindViewHolder(holder: ProfileHolder, position: Int) {
val item = data[position] as DashboardView.Profile
with(holder) {
bindClick(onProfileClicked)
bindName(item.name)
bindAvatar(name = item.name, avatar = item.avatar)
}
}
override fun onBindViewHolder(
holder: ProfileHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
} else {
payloads.typeOf<DesktopDiffUtil.Payload>().forEach { payload ->
val item = data[position] as DashboardView.Profile
with(holder) {
if (payload.titleChanged()) {
bindName(item.name)
}
if (payload.imageChanged()) {
bindAvatar(item.name, item.avatar)
}
}
}
}
}
override fun getItemCount(): Int = data.size
class ProfileHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bindName(name: String) {
itemView.greeting.text = itemView.context.getString(R.string.greet, name)
}
fun bindAvatar(name: String, avatar: String?) {
val pos = name.firstDigitByHash()
itemView.avatar.bind(
name = name,
color = itemView.context.avatarColor(pos)
)
avatar?.let { itemView.avatar.icon(it) }
}
fun bindClick(onClick: () -> Unit) {
itemView.avatar.setOnClickListener { onClick() }
}
}
}

View file

@ -58,15 +58,6 @@ class DesktopDiffUtil(
}
}
if (oldDoc is DashboardView.Profile && newDoc is DashboardView.Profile) {
if (oldDoc.avatar != newDoc.avatar) {
changes.add(IMAGE_CHANGED)
}
if (oldDoc.name != newDoc.name) {
changes.add(TITLE_CHANGED)
}
}
if (oldDoc.isSelected != newDoc.isSelected) {
changes.add(SELECTION_CHANGED)
}

View file

@ -1,39 +0,0 @@
package com.anytypeio.anytype.ui.dashboard
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.anytypeio.anytype.R
import kotlinx.android.synthetic.main.item_profile_container.view.*
class ProfileContainerAdapter(
val adapter: DashboardProfileAdapter
) : RecyclerView.Adapter<ProfileContainerAdapter.ProfileContainerHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileContainerHolder {
val inflater = LayoutInflater.from(parent.context)
val view = inflater.inflate(R.layout.item_profile_container, parent, false)
view.recyclerView.apply {
layoutManager = LinearLayoutManager(parent.context)
val lp = (layoutParams as FrameLayout.LayoutParams)
lp.height = (parent.height / 2) - lp.topMargin - lp.bottomMargin
}
return ProfileContainerHolder(view)
}
override fun onBindViewHolder(holder: ProfileContainerHolder, position: Int) {
holder.bind(adapter)
}
override fun getItemCount(): Int = 1
class ProfileContainerHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(adapter: DashboardProfileAdapter) {
itemView.recyclerView.adapter = adapter
}
}
}

View file

@ -1,6 +1,6 @@
package com.anytypeio.anytype.core_models
data class SearchResult(
val results: List<ObjectWrapper>,
val dependencies: List<ObjectWrapper>
val results: List<ObjectWrapper.Basic>,
val dependencies: List<ObjectWrapper.Basic>
)

View file

@ -0,0 +1,32 @@
package com.anytypeio.anytype.core_models
/**
* Events related to changes or transformations of objects.
* @see ObjectWrapper.Basic
*/
sealed class SubscriptionEvent {
/**
* @property [target] id of the object
* @property [diff] slice of changes to apply to the object
*/
data class Amend(
val target: Id,
val diff: Map<Id, Any?>
) : SubscriptionEvent()
/**
* @property [target] id of the object
* @property [keys] keys, whose values should be removed
*/
data class Unset(
val target: Id,
val keys: List<Id>
) : SubscriptionEvent()
/**
* @property [target] id of the object
* @property [data] new set of data for the object
*/
data class Set(
val target: Id,
val data: Map<String, Any?>
) : SubscriptionEvent()
}

View file

@ -0,0 +1,10 @@
package com.anytypeio.anytype.data.auth.event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.domain.search.SubscriptionEventChannel
class SubscriptionDataChannel(
private val remote: SubscriptionEventRemoteChannel
) : SubscriptionEventChannel {
override fun subscribe(subscriptions: List<Id>) = remote.subscribe(subscriptions)
}

View file

@ -0,0 +1,9 @@
package com.anytypeio.anytype.data.auth.event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.SubscriptionEvent
import kotlinx.coroutines.flow.Flow
interface SubscriptionEventRemoteChannel {
fun subscribe(subscriptions: List<Id>): Flow<List<SubscriptionEvent>>
}

View file

@ -13,6 +13,7 @@ dependencies {
def unitTestDependencies = rootProject.ext.unitTesting
testImplementation unitTestDependencies.kotlinTest
testImplementation unitTestDependencies.turbine
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation unitTestDependencies.coroutineTesting

View file

@ -1,25 +1,87 @@
package com.anytypeio.anytype.domain.auth.interactor
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.SubscriptionEvent
import com.anytypeio.anytype.domain.`object`.amend
import com.anytypeio.anytype.domain.`object`.unset
import com.anytypeio.anytype.domain.base.BaseUseCase
import com.anytypeio.anytype.domain.base.Either
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.core_models.Payload
import com.anytypeio.anytype.domain.search.SubscriptionEventChannel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
/** Use case for getting currently selected user account.
*/
class GetProfile(
private val repo: BlockRepository
) : BaseUseCase<Payload, BaseUseCase.None>() {
private val repo: BlockRepository,
private val channel: SubscriptionEventChannel
) : BaseUseCase<ObjectWrapper.Basic, GetProfile.Params>() {
override suspend fun run(params: None) = try {
fun subscribe(subscription: Id) = channel.subscribe(subscriptions = listOf(subscription))
val config = repo.getConfig()
fun observe(
subscription: Id,
keys: List<String>,
dispatcher: CoroutineDispatcher = Dispatchers.IO
): Flow<ObjectWrapper.Basic> {
val payload = repo.openProfile(config.profile)
Either.Right(payload)
} catch (t: Throwable) {
Either.Left(t)
return flow {
val profile = getProfile(subscription, keys)
emitAll(
channel.subscribe(subscriptions = listOf(subscription)).scan(profile) { prev, payload ->
var result = prev
payload.forEach { event ->
result = when (event) {
is SubscriptionEvent.Amend -> {
result.amend(event.diff)
}
is SubscriptionEvent.Set -> {
ObjectWrapper.Basic(event.data)
}
is SubscriptionEvent.Unset -> {
result.unset(event.keys)
}
}
}
result
}
)
}.flowOn(dispatcher)
}
private suspend fun getProfile(
subscription: Id,
keys: List<String>
): ObjectWrapper.Basic {
val config = repo.getConfig()
val result = repo.searchObjectsByIdWithSubscription(
subscription = subscription,
ids = listOf(config.profile),
keys = keys
)
val profile = result.results.first { obj ->
obj.id == config.profile
}
return profile
}
@Deprecated("Should not be used. Will be changed.")
override suspend fun run(params: Params) = safe {
val config = repo.getConfig()
val result = repo.searchObjectsByIdWithSubscription(
subscription = params.subscription,
ids = listOf(config.profile),
keys = params.keys
)
result.results.find { obj ->
obj.id == config.profile
} ?: throw Exception("Profile not found")
}
class Params(
val subscription: Id,
val keys: List<String>
)
}

View file

@ -0,0 +1,18 @@
package com.anytypeio.anytype.domain.`object`
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectWrapper
/**
* Function for applying granular changes in object, replacing existing values with the new ones.
* @param [diff] difference
*/
fun ObjectWrapper.Basic.amend(diff: Map<Id, Any?>) = ObjectWrapper.Basic(map + diff)
/**
* Function for applying granular changes in object.
*/
fun ObjectWrapper.Basic.unset(keys: List<Id>) = ObjectWrapper.Basic(
map.toMutableMap().apply {
keys.forEach { k -> remove(k) }
}
)

View file

@ -0,0 +1,13 @@
package com.anytypeio.anytype.domain.search
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.SubscriptionEvent
import kotlinx.coroutines.flow.Flow
/**
* Channel for events related to changes and transformations of objects.
* @see SubscriptionEvent
*/
interface SubscriptionEventChannel {
fun subscribe(subscriptions: List<Id>): Flow<List<SubscriptionEvent>>
}

View file

@ -0,0 +1,335 @@
package com.anytypeio.anytype.domain.dashboard
import app.cash.turbine.test
import com.anytypeio.anytype.core_models.*
import com.anytypeio.anytype.domain.auth.interactor.GetProfile
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.common.MockDataFactory
import com.anytypeio.anytype.domain.search.SubscriptionEventChannel
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.stub
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import kotlin.test.assertEquals
class GetProfileTest {
@ExperimentalCoroutinesApi
@get:Rule
var rule = CoroutineTestRule()
@Mock
lateinit var repo: BlockRepository
@Mock
lateinit var channel: SubscriptionEventChannel
private lateinit var usecase: GetProfile
val config = Config(
home = MockDataFactory.randomUuid(),
profile = MockDataFactory.randomUuid(),
gateway = MockDataFactory.randomUuid()
)
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
usecase = GetProfile(
repo = repo,
channel = channel
)
}
@Test
fun `should emit initial data with profile and complete`() = runBlockingTest {
val subscription = MockDataFactory.randomUuid()
val profile = ObjectWrapper.Basic(
mapOf(
Relations.ID to config.profile,
Relations.NAME to "Friedrich Kittler"
)
)
repo.stub {
onBlocking { getConfig() } doReturn config
}
channel.stub {
on { subscribe(listOf(subscription)) } doReturn flowOf()
}
repo.stub {
onBlocking {
searchObjectsByIdWithSubscription(
subscription = subscription,
keys = emptyList(),
ids = listOf(config.profile)
)
} doReturn SearchResult(
results = listOf(profile),
dependencies = emptyList()
)
}
usecase.observe(
subscription = subscription,
keys = emptyList(),
dispatcher = rule.testDispatcher
).test {
assertEquals(
expected = profile.map,
actual = awaitItem().map
)
awaitComplete()
}
}
@Test
fun `should emit transformated profile object, then complete`() = runBlockingTest {
val subscription = MockDataFactory.randomUuid()
val nameBeforeUpdate = "Friedrich"
val nameAfterUpdate = "Friedrich Kittler"
val profileObjectBeforeUpdate = ObjectWrapper.Basic(
mapOf(
Relations.ID to config.profile,
Relations.NAME to nameBeforeUpdate
)
)
val profileObjectAfterUpdate = ObjectWrapper.Basic(
mapOf(
Relations.ID to config.profile,
Relations.NAME to nameAfterUpdate
)
)
repo.stub {
onBlocking { getConfig() } doReturn config
}
channel.stub {
on { subscribe(listOf(subscription)) } doReturn flow {
emit(
listOf(
SubscriptionEvent.Amend(
diff = mapOf(
Relations.NAME to nameAfterUpdate
),
target = config.profile
)
)
)
}
}
repo.stub {
onBlocking {
searchObjectsByIdWithSubscription(
subscription = subscription,
keys = emptyList(),
ids = listOf(config.profile)
)
} doReturn SearchResult(
results = listOf(profileObjectBeforeUpdate),
dependencies = emptyList()
)
}
usecase.observe(
subscription = subscription,
keys = emptyList(),
dispatcher = rule.testDispatcher
).test {
assertEquals(
expected = profileObjectBeforeUpdate.map,
actual = awaitItem().map
)
assertEquals(
expected = profileObjectAfterUpdate.map,
actual = awaitItem().map
)
awaitComplete()
}
}
@Test
fun `should apply several transformations, then complete`() = runBlockingTest {
val subscription = MockDataFactory.randomUuid()
val nameBeforeUpdate = "Friedrich"
val nameAfterUpdate = "Friedrich Kittler"
val iconImageBeforeUpdate = null
val iconImageAfterUpdate = MockDataFactory.randomUuid()
val profileObjectBeforeUpdate = ObjectWrapper.Basic(
mapOf(
Relations.ID to config.profile,
Relations.NAME to nameBeforeUpdate,
Relations.ICON_IMAGE to iconImageBeforeUpdate
)
)
repo.stub {
onBlocking { getConfig() } doReturn config
}
channel.stub {
on { subscribe(listOf(subscription)) } doReturn flow {
emit(
listOf(
SubscriptionEvent.Amend(
diff = mapOf(
Relations.NAME to nameAfterUpdate
),
target = config.profile
),
SubscriptionEvent.Amend(
diff = mapOf(
Relations.ICON_IMAGE to iconImageAfterUpdate
),
target = config.profile
)
)
)
}
}
repo.stub {
onBlocking {
searchObjectsByIdWithSubscription(
subscription = subscription,
keys = emptyList(),
ids = listOf(config.profile)
)
} doReturn SearchResult(
results = listOf(profileObjectBeforeUpdate),
dependencies = emptyList()
)
}
usecase.observe(
subscription = subscription,
keys = emptyList(),
dispatcher = rule.testDispatcher
).test {
assertEquals(
expected = profileObjectBeforeUpdate.map,
actual = awaitItem().map
)
assertEquals(
expected = mapOf(
Relations.ID to config.profile,
Relations.NAME to nameAfterUpdate,
Relations.ICON_IMAGE to iconImageAfterUpdate
),
actual = awaitItem().map
)
awaitComplete()
}
}
@Test
fun `should apply all transformations, then complete`() = runBlockingTest {
val subscription = MockDataFactory.randomUuid()
val nameBeforeUpdate = "Friedrich"
val nameAfterUpdate = "Friedrich Kittler"
val iconImageBeforeUpdate = null
val iconImageAfterUpdate = MockDataFactory.randomUuid()
val profileObjectBeforeUpdate = ObjectWrapper.Basic(
mapOf(
Relations.ID to config.profile,
Relations.NAME to nameBeforeUpdate,
Relations.ICON_IMAGE to iconImageBeforeUpdate
)
)
repo.stub {
onBlocking { getConfig() } doReturn config
}
channel.stub {
on { subscribe(listOf(subscription)) } doReturn flow {
emit(
listOf(
SubscriptionEvent.Amend(
diff = mapOf(
Relations.NAME to nameAfterUpdate
),
target = config.profile
)
)
)
emit(
listOf(
SubscriptionEvent.Amend(
diff = mapOf(
Relations.ICON_IMAGE to iconImageAfterUpdate
),
target = config.profile
)
)
)
}
}
repo.stub {
onBlocking {
searchObjectsByIdWithSubscription(
subscription = subscription,
keys = emptyList(),
ids = listOf(config.profile)
)
} doReturn SearchResult(
results = listOf(profileObjectBeforeUpdate),
dependencies = emptyList()
)
}
usecase.observe(
subscription = subscription,
keys = emptyList(),
dispatcher = rule.testDispatcher
).test {
assertEquals(
expected = profileObjectBeforeUpdate.map,
actual = awaitItem().map
)
assertEquals(
expected = mapOf(
Relations.ID to config.profile,
Relations.NAME to nameAfterUpdate,
Relations.ICON_IMAGE to iconImageBeforeUpdate
),
actual = awaitItem().map
)
assertEquals(
expected = mapOf(
Relations.ID to config.profile,
Relations.NAME to nameAfterUpdate,
Relations.ICON_IMAGE to iconImageAfterUpdate
),
actual = awaitItem().map
)
awaitComplete()
}
}
}

View file

@ -0,0 +1,92 @@
package com.anytypeio.anytype.domain.ext
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.domain.`object`.amend
import com.anytypeio.anytype.domain.`object`.unset
import com.anytypeio.anytype.domain.common.MockDataFactory
import org.junit.Test
import kotlin.test.assertEquals
class ObjectWrapperExtTest {
@Test
fun `should update several fields with amend operation and preserve old fields - amend operation`() {
val initial = ObjectWrapper.Basic(
mapOf(
Relations.NAME to "Friedrich Kittler",
Relations.DONE to false,
Relations.DESCRIPTION to null,
Relations.IS_FAVORITE to false
)
)
val expected = ObjectWrapper.Basic(
mapOf(
Relations.NAME to "Friedrich Kittler",
Relations.DONE to true,
Relations.DESCRIPTION to "German media philosopher",
Relations.IS_FAVORITE to true
)
)
val result = initial.amend(
mapOf(
Relations.DONE to true,
Relations.IS_FAVORITE to true,
Relations.DESCRIPTION to "German media philosopher",
)
)
assertEquals(
expected = expected.map,
actual = result.map
)
}
@Test
fun `should remove several fields - unset operation`() {
val firstRelationId = MockDataFactory.randomUuid()
val firstRelationValue = MockDataFactory.randomString()
val secondRelationId = MockDataFactory.randomUuid()
val secondRelationValue = MockDataFactory.randomInt()
val thirdRelationId = MockDataFactory.randomUuid()
val thirdRelationValue = MockDataFactory.randomBoolean()
val initial = ObjectWrapper.Basic(
mapOf(
Relations.NAME to "Friedrich Kittler",
Relations.DONE to false,
Relations.DESCRIPTION to null,
Relations.IS_FAVORITE to false,
firstRelationId to firstRelationValue,
secondRelationId to secondRelationValue,
thirdRelationId to thirdRelationValue
)
)
val expected = ObjectWrapper.Basic(
mapOf(
Relations.NAME to "Friedrich Kittler",
Relations.DONE to false,
Relations.DESCRIPTION to null,
Relations.IS_FAVORITE to false,
)
)
val result = initial.unset(
keys = listOf(
firstRelationId,
secondRelationId,
thirdRelationId
)
)
assertEquals(
expected = expected.map,
actual = result.map
)
}
}

View file

@ -0,0 +1,63 @@
package com.anytypeio.anytype.middleware.interactor
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.SubscriptionEvent
import com.anytypeio.anytype.data.auth.event.SubscriptionEventRemoteChannel
import com.anytypeio.anytype.middleware.EventProxy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapNotNull
class MiddlewareSubscriptionEventChannel(
private val events: EventProxy
) : SubscriptionEventRemoteChannel {
override fun subscribe(subscriptions: List<Id>) = events
.flow()
.mapNotNull { payload ->
payload.messages.mapNotNull { e ->
when {
e.objectDetailsAmend != null -> {
val event = e.objectDetailsAmend
checkNotNull(event)
if (subscriptions.any { it in event.subIds }) {
SubscriptionEvent.Amend(
target = event.id,
diff = event.details.associate { it.key to it.value }
)
} else {
null
}
}
e.objectDetailsUnset != null -> {
val event = e.objectDetailsUnset
checkNotNull(event)
if (subscriptions.any { it in event.subIds }) {
SubscriptionEvent.Unset(
target = event.id,
keys = event.keys
)
} else {
null
}
}
e.objectDetailsSet != null -> {
val event = e.objectDetailsSet
checkNotNull(event)
val data = event.details
if (subscriptions.any { it in event.subIds } && data != null) {
SubscriptionEvent.Set(
target = event.id,
data = data
)
} else {
null
}
}
else -> {
null
}
}
}
}
.filter { it.isNotEmpty() }
}

View file

@ -0,0 +1,10 @@
package com.anytypeio.anytype.presentation.dashboard
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Url
data class DashboardProfileView(
val id: Id,
val name: String,
val image: Url?
)

View file

@ -2,7 +2,6 @@ package com.anytypeio.anytype.presentation.dashboard
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.Url
import com.anytypeio.anytype.presentation.objects.ObjectIcon
sealed class DashboardView {
@ -12,15 +11,6 @@ sealed class DashboardView {
abstract val isSelected: Boolean
abstract val isLoading: Boolean
data class Profile(
override val id: Id,
val name: String,
val avatar: Url? = null,
override val isArchived: Boolean = false,
override val isSelected: Boolean = false,
override val isLoading: Boolean = false
) : DashboardView()
data class Document(
override val id: Id,
val target: Id,

View file

@ -34,14 +34,6 @@ interface HomeDashboardEventConverter {
objectTypes = objectTypesProvider.get()
)
}
SmartBlockType.PROFILE_PAGE -> {
HomeDashboardStateMachine.Event.OnShowProfile(
blocks = event.blocks,
context = event.context,
details = event.details,
builder = builder
)
}
else -> {
null
}

View file

@ -75,13 +75,6 @@ sealed class HomeDashboardStateMachine {
val objectTypes: List<ObjectType>
) : Event()
data class OnShowProfile(
val context: String,
val blocks: List<Block>,
val details: Block.Details,
val builder: UrlBuilder
) : Event()
data class OnDetailsUpdated(
val context: String,
val target: String,
@ -157,45 +150,24 @@ sealed class HomeDashboardStateMachine {
)
is Event.OnShowDashboard -> {
val current = state.blocks.filterIsInstance<DashboardView.Profile>()
val new = event.blocks
.toDashboardViews(
val new = event.blocks.toDashboardViews(
details = event.details,
builder = event.builder,
objectTypes = event.objectTypes
)
val childrenIdsList = event.blocks.getChildrenIdsList(
parent = event.context
)
val childrenIdsList = event.blocks.getChildrenIdsList(parent = event.context)
state.copy(
isInitialzed = true,
isLoading = false,
error = null,
blocks = current.addAndSortByIds(childrenIdsList, new),
blocks = new,
childrenIdsList = childrenIdsList,
objectTypes = event.objectTypes,
details = event.details
)
}
is Event.OnShowProfile -> {
val current = state.blocks.filter { it !is DashboardView.Profile }
val new = event.blocks.toDashboardViews(
details = event.details,
builder = event.builder
).filterIsInstance<DashboardView.Profile>()
state.copy(
isInitialzed = true,
isLoading = false,
error = null,
blocks = current.addAndSortByIds(state.childrenIdsList, new)
)
}
is Event.OnStartedCreatingPage -> state.copy(
isLoading = true
)

View file

@ -16,15 +16,12 @@ import com.anytypeio.anytype.analytics.base.EventsDictionary.TAB_RECENT
import com.anytypeio.anytype.analytics.base.EventsDictionary.TAB_SETS
import com.anytypeio.anytype.analytics.base.sendEvent
import com.anytypeio.anytype.analytics.props.Props
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.Position
import com.anytypeio.anytype.core_models.*
import com.anytypeio.anytype.core_utils.common.EventWrapper
import com.anytypeio.anytype.core_utils.ext.withLatestFrom
import com.anytypeio.anytype.core_utils.ui.ViewState
import com.anytypeio.anytype.core_utils.ui.ViewStateViewModel
import com.anytypeio.anytype.domain.auth.interactor.GetProfile
import com.anytypeio.anytype.domain.base.BaseUseCase
import com.anytypeio.anytype.domain.block.interactor.Move
import com.anytypeio.anytype.domain.config.FlavourConfigProvider
import com.anytypeio.anytype.domain.config.GetConfig
@ -48,6 +45,7 @@ import com.anytypeio.anytype.presentation.objects.ObjectIcon
import com.anytypeio.anytype.presentation.objects.SupportedLayouts
import com.anytypeio.anytype.presentation.objects.getProperName
import com.anytypeio.anytype.presentation.search.ObjectSearchConstants
import com.anytypeio.anytype.presentation.search.Subscriptions
import com.anytypeio.anytype.presentation.settings.EditorSettings
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
@ -76,7 +74,6 @@ class HomeDashboardViewModel(
HomeDashboardEventConverter by eventConverter,
SupportNavigation<EventWrapper<AppNavigation.Command>> {
private val isProfileNavigationEnabled = MutableStateFlow(false)
val toasts = MutableSharedFlow<String>()
private val machine = Interactor(scope = viewModelScope)
@ -89,7 +86,6 @@ class HomeDashboardViewModel(
override val navigation = MutableLiveData<EventWrapper<AppNavigation.Command>>()
private var ctx: Id = ""
private var profile: Id = ""
val tabs = MutableStateFlow(listOf(TAB.FAVOURITE, TAB.RECENT, TAB.SETS, TAB.BIN))
@ -109,9 +105,23 @@ class HomeDashboardViewModel(
private val views: List<DashboardView>
get() = stateData.value?.blocks ?: emptyList()
val profile = MutableStateFlow<ViewState<ObjectWrapper.Basic>>(ViewState.Init)
init {
startProcessingState()
proceedWithGettingConfig()
viewModelScope.launch {
getProfile.observe(
subscription = Subscriptions.SUBSCRIPTION_PROFILE,
keys = listOf(
Relations.ID,
Relations.NAME,
Relations.ICON_IMAGE
)
).collect {
profile.value = ViewState.Success(it)
}
}
}
private fun startProcessingState() {
@ -134,8 +144,6 @@ class HomeDashboardViewModel(
result.either(
fnR = { config ->
ctx = config.home
profile = config.profile
isProfileNavigationEnabled.value = true
startInterceptingEvents(context = config.home)
processDragAndDrop(context = config.home)
},
@ -144,15 +152,6 @@ class HomeDashboardViewModel(
}
}
private fun proceedWithGettingAccount() {
getProfile(viewModelScope, BaseUseCase.None) { result ->
result.either(
fnL = { Timber.e(it, "Error while getting account") },
fnR = { payload -> processEvents(payload.events) }
)
}
}
private fun processDragAndDrop(context: String) {
viewModelScope.launch {
dropChanges
@ -193,7 +192,6 @@ class HomeDashboardViewModel(
fun onViewCreated() {
Timber.d("onViewCreated, ")
proceedWithGettingAccount()
proceedWithOpeningHomeDashboard()
}
@ -346,14 +344,19 @@ class HomeDashboardViewModel(
fun onAvatarClicked() {
Timber.d("onAvatarClicked, ")
if (isProfileNavigationEnabled.value) {
viewModelScope.sendEvent(
analytics = analytics,
eventName = SCREEN_PROFILE
)
proceedWithOpeningDocument(profile)
} else {
toast("Profile is not ready yet. Please, try again later.")
profile.value.let { state ->
when(state) {
is ViewState.Success -> {
viewModelScope.sendEvent(
analytics = analytics,
eventName = SCREEN_PROFILE
)
proceedWithOpeningDocument(state.data.id)
}
else -> {
toast("Profile is not ready yet. Please, try again later.")
}
}
}
}

View file

@ -24,7 +24,7 @@ fun List<DashboardView>.sortByIds(
}
fun List<DashboardView>.filterByNotArchivedPages(): List<DashboardView> =
this.filterNot { it is DashboardView.Profile || it.isArchived }
this.filterNot { it.isArchived }
fun List<DashboardView>.updateDetails(
target: String,
@ -34,19 +34,6 @@ fun List<DashboardView>.updateDetails(
): List<DashboardView> {
return mapNotNull { view ->
when (view) {
is DashboardView.Profile -> {
if (view.id == target) {
view.copy(
name = details.name.orEmpty(),
avatar = details.iconImage.let {
if (it.isNullOrEmpty()) null
else builder.image(it)
}
)
} else {
view
}
}
is DashboardView.Document -> {
if (view.target == target) {
val obj = ObjectWrapper.Basic(details.map)

View file

@ -258,21 +258,6 @@ fun List<Block>.toDashboardViews(
objectTypes: List<ObjectType> = emptyList()
): List<DashboardView> = this.mapNotNull { block ->
when (val content = block.content) {
is Block.Content.Smart -> {
when (content.type) {
SmartBlockType.PROFILE_PAGE -> {
DashboardView.Profile(
id = block.id,
name = details.details[block.id]?.name.orEmpty(),
avatar = details.details[block.id]?.iconImage.let {
if (it.isNullOrEmpty()) null
else builder.image(it)
}
)
}
else -> null
}
}
is Block.Content.Link -> {
val targetDetails = details.details[content.target]
val typeUrl = targetDetails?.map?.type

View file

@ -0,0 +1,5 @@
package com.anytypeio.anytype.presentation.search
object Subscriptions {
const val SUBSCRIPTION_PROFILE = "subscription.profile"
}

View file

@ -10,7 +10,8 @@ import com.anytypeio.anytype.domain.auth.interactor.GetProfile
import com.anytypeio.anytype.domain.base.Either
import com.anytypeio.anytype.domain.block.interactor.Move
import com.anytypeio.anytype.domain.config.*
import com.anytypeio.anytype.domain.dashboard.interactor.*
import com.anytypeio.anytype.domain.dashboard.interactor.CloseDashboard
import com.anytypeio.anytype.domain.dashboard.interactor.OpenDashboard
import com.anytypeio.anytype.domain.dataview.interactor.SearchObjects
import com.anytypeio.anytype.domain.event.interactor.InterceptEvents
import com.anytypeio.anytype.domain.launch.GetDefaultEditorType
@ -22,6 +23,7 @@ import com.anytypeio.anytype.presentation.navigation.AppNavigation
import com.anytypeio.anytype.presentation.util.CoroutinesTestRule
import com.jraska.livedata.test
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
@ -135,6 +137,7 @@ class HomeDashboardViewModelTest {
stubGetConfig(response)
stubObserveEvents(params = InterceptEvents.Params(context = null))
stubObserveProfile()
// TESTING
@ -142,7 +145,6 @@ class HomeDashboardViewModelTest {
verify(getConfig, times(1)).invoke(any(), any(), any())
verifyZeroInteractions(openDashboard)
verifyZeroInteractions(getProfile)
}
@Test
@ -174,6 +176,7 @@ class HomeDashboardViewModelTest {
stubGetConfig(response)
stubObserveEvents(params = InterceptEvents.Params(context = config.home))
stubObserveProfile()
// TESTING
@ -181,7 +184,6 @@ class HomeDashboardViewModelTest {
verify(getConfig, times(1)).invoke(any(), any(), any())
verifyZeroInteractions(openDashboard)
verifyZeroInteractions(getProfile)
}
@Test
@ -286,27 +288,25 @@ class HomeDashboardViewModelTest {
}
@Test
fun `should proceed with getting profile and opening dashboard when view is created`() {
fun `should proceed opening dashboard when view is created`() {
// SETUP
stubGetConfig(Either.Right(config))
stubObserveEvents(params = InterceptEvents.Params(context = config.home))
stubOpenDashboard()
stubObserveProfile()
// TESTING
vm = buildViewModel()
vm.onViewCreated()
verify(getProfile, times(1)).invoke(any(), any(), any())
runBlockingTest {
verify(openDashboard, times(1)).invoke(eq(null))
}
}
@Test
fun `should start creating page when requested from UI`() {
@ -395,7 +395,24 @@ class HomeDashboardViewModelTest {
private fun stubGetDefaultObjectType(type: String? = null, name: String? = null) {
getDefaultEditorType.stub {
onBlocking { invoke(Unit) } doReturn Either.Right(GetDefaultEditorType.Response(type, name))
onBlocking { invoke(Unit) } doReturn Either.Right(
GetDefaultEditorType.Response(
type,
name
)
)
}
}
private fun stubObserveProfile() {
getProfile.stub {
on {
observe(
subscription = any(),
keys = any(),
dispatcher = any()
)
} doReturn emptyFlow()
}
}
}

View file

@ -670,7 +670,6 @@ class DashboardViewExtensionKtTest {
val target1 = MockDataFactory.randomUuid()
val id2 = MockDataFactory.randomUuid()
val target2 = MockDataFactory.randomUuid()
val id3 = MockDataFactory.randomUuid()
val views = listOf(
DashboardView.Document(
@ -686,11 +685,6 @@ class DashboardViewExtensionKtTest {
target = target2,
title = "Title2"
),
DashboardView.Profile(
isArchived = false,
id = id3,
name = "Profile"
),
DashboardView.Document(
isArchived = false,
id = "profileId",